From aacc900fcd86d1d7013cbf2b038e05757be87df6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:48:50 +0000 Subject: [PATCH 1/8] Security and robustness fixes from repo audit Security: - menubar: escape asset_type (was printed raw before SwiftBar attributes, allowing an upstream value to inject a clickable bash= action); sbEscape now also strips newlines/control chars so upstream text can't add lines. - oauth: never echo token/registration response bodies in errors (a malformed 200 leaked access/refresh tokens into agent.log); cap reads. - oauth: callbacks without our state are ignored instead of aborting the login, so any web page can't cancel an in-progress login; constant-time state compare; ReadHeaderTimeout on the callback server. - menubar install: validate refresh_interval (used in the plugin filename) and shell-quote the binary path in the generated script. - invoke security/osascript/launchctl/open/defaults/tail by absolute path. - CLI output strips control characters from server-supplied names. - CI: least-privilege permissions, SHA-pinned actions, no persisted credentials, pinned govulncheck, and run go test. Robustness: - serialise token refreshes across processes with a lock file and re-read Keychain after acquiring it, so the poller and menu bar can't burn a rotated refresh token; keep refreshed tokens in memory if saving fails. - keychain save uses -U only (no delete-then-add window). - MCP client refreshes and retries once on HTTP 401. - due-date alerts compare local calendar dates (were off by one: "due tomorrow" showed as 0d and due-today never alerted). - launchd schedule converts weekday as well as time to local zone and uses the current DST offset; cadence now matches the documented 10 minutes. - watchlist dedupe no longer collapses ticker-only entries; prune stale debounce keys; rune-safe truncation/padding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TT1VrXR9CkmWFfRQN1H87J --- .github/workflows/ci.yml | 16 +++- .github/workflows/codeql.yml | 10 ++- SECURITY.md | 6 +- cmd/indw/main.go | 130 ++++++++++++++++-------------- cmd/indw/main_test.go | 75 +++++++++++++++++ cmd/indw/menubar.go | 61 +++++++++++--- cmd/indw/menubar_test.go | 56 +++++++++++++ internal/alert/engine.go | 49 +++++++++-- internal/alert/engine_test.go | 47 +++++++++++ internal/indmoney/api.go | 22 +++-- internal/indmoney/api_test.go | 14 ++++ internal/mcpclient/client.go | 33 +++++++- internal/mcpclient/client_test.go | 59 ++++++++++++++ internal/notify/macos.go | 2 +- internal/oauth/oauth.go | 109 ++++++++++++++++++------- internal/oauth/oauth_test.go | 17 ++++ internal/store/keychain.go | 97 +++++++++++++++++----- internal/store/keychain_test.go | 6 +- internal/store/lock.go | 34 ++++++++ 19 files changed, 698 insertions(+), 145 deletions(-) create mode 100644 cmd/indw/main_test.go create mode 100644 cmd/indw/menubar_test.go create mode 100644 internal/alert/engine_test.go create mode 100644 internal/indmoney/api_test.go create mode 100644 internal/mcpclient/client_test.go create mode 100644 internal/oauth/oauth_test.go create mode 100644 internal/store/lock.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e9b7c5..2e5b325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,20 @@ on: pull_request: branches: [main] +# Least privilege: CI only needs to read the repo. +permissions: + contents: read + jobs: build: runs-on: macos-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + # Actions are pinned to commit SHAs (Dependabot keeps them current); + # a moved or compromised tag can't change what runs here. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod check-latest: true @@ -19,7 +27,9 @@ jobs: run: go build ./... - name: Vet run: go vet ./... + - name: Test + run: go test ./... - name: Govulncheck run: | - go install golang.org/x/vuln/cmd/govulncheck@latest + go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 govulncheck ./... diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 43614f8..c7e8b47 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,15 +20,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@1190a975f95ce23525efb6a3fc21ea29567c1b52 # v3.38.2 with: languages: go queries: security-and-quality @@ -37,6 +39,6 @@ jobs: run: go build ./... - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@1190a975f95ce23525efb6a3fc21ea29567c1b52 # v3.38.2 with: category: "/language:go" diff --git a/SECURITY.md b/SECURITY.md index d8b6112..d041026 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,7 +39,11 @@ Out of scope: ## Hardening notes for self-deployers -- OAuth tokens live in macOS Keychain, not in files. The fallback case is also covered in the audit notes. +- OAuth tokens live in macOS Keychain, not in files. **Limitation:** the item is created via `/usr/bin/security`, so its access list trusts that tool, and any process running as your user can read it without a prompt (`security find-generic-password -s indmoney-watch -w`). Keychain storage protects against other users and offline disk access, not against malware running in your own account. If that matters to you, open Keychain Access and restrict the `indmoney-watch` item's access control, or revoke the session from INDmoney after use. +- Token refreshes are serialised across processes (launchd poller, SwiftBar plugin, CLI) with a lock file in `~/.config/indmoney-watch/`, so concurrent refreshes can't burn a rotated refresh token. +- Errors from the OAuth endpoints only ever report the OAuth error code, never the response body, so tokens and client secrets don't end up in `agent.log`. `agent.log` does contain alert text (holdings, P&L, card dues); it lives in the owner-only (`0700`) config directory. +- Text from INDmoney (names, asset types, error bodies) is stripped of `|`, newlines and control characters before it reaches the SwiftBar plugin output or your terminal, so it can't add clickable `bash=` actions or emit terminal escape sequences. +- System tools (`security`, `osascript`, `launchctl`, `open`, `defaults`, `tail`) are invoked by absolute path, never looked up via `$PATH`. - The SwiftBar plugin script is installed with `0700` (owner-only) to defend against local plugin-swap attacks. If you're upgrading from an older `indw`, re-run `indw menubar install` to apply the tighter perms. - macOS notifications are rendered via `osascript` with strings passed through environment variables, not concatenated into the AppleScript body — INDmoney-supplied names cannot inject AppleScript. diff --git a/cmd/indw/main.go b/cmd/indw/main.go index ffea273..628be3c 100644 --- a/cmd/indw/main.go +++ b/cmd/indw/main.go @@ -13,6 +13,8 @@ import ( "strings" "text/tabwriter" "time" + "unicode" + "unicode/utf8" "github.com/abinashstack/indmoney-watch/internal/alert" "github.com/abinashstack/indmoney-watch/internal/config" @@ -25,8 +27,10 @@ import ( ) const ( - mcpEndpoint = "https://mcp.indmoney.com/mcp" - launchdLabel = "indmoney-watch" + mcpEndpoint = "https://mcp.indmoney.com/mcp" + launchdLabel = "indmoney-watch" + // pollEvery is the launchd agent's cadence during market hours. + pollEvery = 10 * time.Minute ) func usage() { @@ -41,7 +45,7 @@ Usage: indw set-target SYMBOL below PRICE Add price target (below) indw clear-target SYMBOL Remove targets for a symbol indw run-once Single poll (alerts fire if thresholds hit) - indw start Install launchd agent (poll every 10 min, 09:00–16:00 IST) + indw start Install launchd agent (poll every 10 min, Mon–Fri 09:00–16:00 IST) indw stop Uninstall launchd agent indw config Print config path and contents indw logs [-f] Show launchd agent log (-f to follow) @@ -118,20 +122,14 @@ func cmdLogin(ctx context.Context) error { } } if !force { - if t, err := store.LoadTokens(); err == nil { - if time.Until(t.ExpiresAt) > 60*time.Second { - fmt.Println("Already logged in. Token valid until", t.ExpiresAt.Local().Format("2006-01-02 15:04 MST")) + // Go through TokenSource so a refresh here takes the same + // cross-process lock as the daemon and menu bar plugin. + if ts, err := store.NewTokenSource(); err == nil { + if _, err := ts.AccessToken(ctx); err == nil { + fmt.Println("Logged in. Token valid until", ts.ExpiresAt().Local().Format("2006-01-02 15:04 MST")) fmt.Println("Use `indw login --force` to re-authenticate.") return nil } - if t.RefreshToken != "" { - if nt, rerr := oauth.Refresh(ctx, t); rerr == nil { - if serr := store.SaveTokens(nt); serr == nil { - fmt.Println("Refreshed existing session. Token valid until", nt.ExpiresAt.Local().Format("2006-01-02 15:04 MST")) - return nil - } - } - } fmt.Println("Existing tokens expired and could not be refreshed; starting full login.") } } @@ -188,13 +186,13 @@ func cmdStatus(ctx context.Context) error { fmt.Fprintln(tw) fmt.Fprintln(tw, "By asset type:") for _, inv := range snap.Investments { - fmt.Fprintf(tw, " %s\t₹%.0f\t%+.2f%%\n", inv.AssetType, inv.CurrentValue, inv.ReturnPercentage) + fmt.Fprintf(tw, " %s\t₹%.0f\t%+.2f%%\n", termSafe(inv.AssetType), inv.CurrentValue, inv.ReturnPercentage) } if len(snap.Liabilities.CreditCards) > 0 { fmt.Fprintln(tw) fmt.Fprintln(tw, "Credit cards:") for _, cc := range snap.Liabilities.CreditCards { - fmt.Fprintf(tw, " %s\t₹%.2f due %s\n", cc.Name, cc.TotalDue, cc.DueDate) + fmt.Fprintf(tw, " %s\t₹%.2f due %s\n", termSafe(cc.Name), cc.TotalDue, termSafe(cc.DueDate)) } } return tw.Flush() @@ -221,8 +219,8 @@ func cmdWatchlist(ctx context.Context) error { if details, err := api.IndianStockDetails(ctx, indKeys); err == nil { for k, ent := range details { fmt.Fprintf(tw, "IND\t%s\t%s\t₹%.2f\t%+.2f\t%s\n", - ent.Basic.Symbol, trunc(ent.Basic.Name, 30), - ent.Stats.LivePrice, ent.Stats.DayChangePct, k) + termSafe(ent.Basic.Symbol), trunc(ent.Basic.Name, 30), + ent.Stats.LivePrice, ent.Stats.DayChangePct, termSafe(k)) } } } @@ -241,8 +239,8 @@ func cmdWatchlist(ctx context.Context) error { if details, err := api.USStockDetails(ctx, tickers); err == nil { for tkr, ent := range details { fmt.Fprintf(tw, "US\t%s\t%s\t$%.2f\t%+.2f\t%s\n", - ent.Basic.Symbol, trunc(ent.Basic.Name, 30), - ent.Stats.LivePrice, ent.Stats.DayChangePct, tkr) + termSafe(ent.Basic.Symbol), trunc(ent.Basic.Name, 30), + ent.Stats.LivePrice, ent.Stats.DayChangePct, termSafe(tkr)) } } else { fmt.Fprintf(tw, "US\t-\tcouldn't fetch details (%v)\t\t\t\n", err) @@ -309,7 +307,7 @@ func printSIPRow(tw *tabwriter.Writer, kind string, s indmoney.SIP) { next = "-" } fmt.Fprintf(tw, "%s\t%s\t₹%.0f\t%s\t%s\t%s\n", - kind, trunc(s.DisplayName(), 32), s.AmountValue(), freq, next, status) + kind, trunc(s.DisplayName(), 32), s.AmountValue(), termSafe(freq), termSafe(next), status) } func cmdSetTarget(args []string) error { @@ -359,8 +357,7 @@ func cmdRunOnce(ctx context.Context) error { // Cap the entire cycle. The HTTP client has a 30 s per-request timeout but // the engine fires ~10–20 sequential MCP calls, so a degraded upstream can // otherwise drag a single run past the next launchd slot. 2 minutes is - // generous for a healthy poll and leaves headroom before the next 5 min - // fire. + // generous for a healthy poll and leaves headroom before the next fire. ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() @@ -480,7 +477,7 @@ func cmdStart() error { -`, launchdLabel, plistEscape(exe), calendarSlots(), plistEscape(logFile), plistEscape(logFile)) +`, launchdLabel, plistEscape(exe), calendarSlots(time.Now(), time.Local), plistEscape(logFile), plistEscape(logFile)) pp, err := plistPath() if err != nil { @@ -495,14 +492,18 @@ func cmdStart() error { // Bootstrap. uid := os.Getuid() target := fmt.Sprintf("gui/%d", uid) - _ = exec.Command("launchctl", "bootout", target, pp).Run() - out, err := exec.Command("launchctl", "bootstrap", target, pp).CombinedOutput() + _ = exec.Command("/bin/launchctl", "bootout", target, pp).Run() + out, err := exec.Command("/bin/launchctl", "bootstrap", target, pp).CombinedOutput() if err != nil { return fmt.Errorf("launchctl bootstrap: %w (%s)", err, strings.TrimSpace(string(out))) } fmt.Println("Installed launchd agent:", pp) fmt.Println("Logs:", logFile) - fmt.Println("It will run every 5 minutes between 09:00–16:00 IST, Mon–Fri.") + fmt.Printf("It will run every %d minutes between 09:00–16:00 IST, Mon–Fri.\n", int(pollEvery.Minutes())) + if _, off := time.Now().Zone(); off != 5*3600+30*60 { + fmt.Println("Note: the schedule is converted to your local time zone at install time.") + fmt.Println("If your clock changes for daylight saving, re-run `indw start`.") + } return nil } @@ -513,7 +514,7 @@ func cmdStop() error { } uid := os.Getuid() target := fmt.Sprintf("gui/%d", uid) - _ = exec.Command("launchctl", "bootout", target, pp).Run() + _ = exec.Command("/bin/launchctl", "bootout", target, pp).Run() if err := os.Remove(pp); err != nil && !os.IsNotExist(err) { return err } @@ -521,49 +522,60 @@ func cmdStop() error { return nil } -// calendarSlots returns StartCalendarInterval entries for every 5 minutes -// between 09:00 and 16:00 IST on Mon-Fri. macOS launchd uses local time, -// so we offset for IST (+05:30) → local. The host's local TZ matters; we -// emit IST minute-of-day slots based on the host's current offset. -func calendarSlots() string { - // Convert IST hours to host-local hours. - // 09:00 IST → host local time of 09:00 IST. +// calendarSlots returns StartCalendarInterval entries for every pollEvery +// between 09:00 and 16:00 IST on Mon–Fri, expressed in host-local time +// (launchd only understands local time). +// +// Each slot is converted individually, weekday included: for hosts far from +// IST the market window falls on a different local day (09:00 IST Monday is +// Sunday evening in the US), so reusing the IST weekday would schedule polls +// on the wrong days. The conversion uses the IST week containing now, so the +// UTC offset matches the host's current daylight-saving state. +func calendarSlots(now time.Time, local *time.Location) string { istLoc, err := time.LoadLocation("Asia/Kolkata") if err != nil { istLoc = time.FixedZone("IST", 5*3600+30*60) } + t := now.In(istLoc) + daysSinceMonday := (int(t.Weekday()) + 6) % 7 + monday := time.Date(t.Year(), t.Month(), t.Day()-daysSinceMonday, 0, 0, 0, 0, istLoc) + var sb strings.Builder - weekdays := []int{1, 2, 3, 4, 5} // Mon-Fri - // 09:00 to 15:55 IST in 5-min steps (last fire 15:55). - for h := 9; h <= 15; h++ { - for m := 0; m < 60; m += 5 { - istT := time.Date(2026, 1, 5, h, m, 0, 0, istLoc) // any Monday - localT := istT.Local() - for _, wd := range weekdays { - sb.WriteString(fmt.Sprintf( - " Weekday%dHour%dMinute%d\n", - wd, localT.Hour(), localT.Minute(), - )) - } + for d := 0; d < 5; d++ { // Mon–Fri IST + start := monday.AddDate(0, 0, d).Add(9 * time.Hour) + end := monday.AddDate(0, 0, d).Add(16 * time.Hour) + for slot := start; !slot.After(end); slot = slot.Add(pollEvery) { + lt := slot.In(local) + fmt.Fprintf(&sb, + " Weekday%dHour%dMinute%d\n", + int(lt.Weekday()), lt.Hour(), lt.Minute(), + ) } } - // One last slot at 16:00 IST. - istT := time.Date(2026, 1, 5, 16, 0, 0, 0, istLoc) - localT := istT.Local() - for _, wd := range weekdays { - sb.WriteString(fmt.Sprintf( - " Weekday%dHour%dMinute%d\n", - wd, localT.Hour(), localT.Minute(), - )) - } return sb.String() } +// trunc shortens s to at most n characters (runes, so multi-byte characters +// are never split) and strips terminal control characters. func trunc(s string, n int) string { - if len(s) <= n { + s = termSafe(s) + if utf8.RuneCountInString(s) <= n { return s } - return s[:n-1] + "…" + r := []rune(s) + return string(r[:n-1]) + "…" +} + +// termSafe replaces control characters (including ESC, which starts ANSI +// sequences) with spaces, so names from INDmoney can't rewrite the terminal, +// set its title, or break tabwriter columns. +func termSafe(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, s) } // ---- inspection commands ---- @@ -585,7 +597,7 @@ func cmdLogs(args []string) error { } } tailArgs = append(tailArgs, logFile) - c := exec.Command("tail", tailArgs...) + c := exec.Command("/usr/bin/tail", tailArgs...) c.Stdout = os.Stdout c.Stderr = os.Stderr return c.Run() diff --git a/cmd/indw/main_test.go b/cmd/indw/main_test.go new file mode 100644 index 0000000..f550b38 --- /dev/null +++ b/cmd/indw/main_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "regexp" + "strconv" + "testing" + "time" + "unicode/utf8" +) + +var slotRe = regexp.MustCompile(`Weekday(\d)Hour(\d+)Minute(\d+)`) + +type slot struct{ wd, h, m int } + +func parseSlots(t *testing.T, s string) []slot { + t.Helper() + var out []slot + for _, m := range slotRe.FindAllStringSubmatch(s, -1) { + wd, _ := strconv.Atoi(m[1]) + h, _ := strconv.Atoi(m[2]) + mi, _ := strconv.Atoi(m[3]) + out = append(out, slot{wd, h, mi}) + } + return out +} + +func mustLoc(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Skipf("tzdata for %s unavailable: %v", name, err) + } + return loc +} + +func TestCalendarSlotsIST(t *testing.T) { + ist := mustLoc(t, "Asia/Kolkata") + slots := parseSlots(t, calendarSlots(time.Date(2026, 10, 7, 12, 0, 0, 0, ist), ist)) + // 09:00–16:00 inclusive every 10 min = 43 per day, Mon–Fri. + if len(slots) != 43*5 { + t.Fatalf("got %d slots, want %d", len(slots), 43*5) + } + first, last := slots[0], slots[len(slots)-1] + if first != (slot{1, 9, 0}) || last != (slot{5, 16, 0}) { + t.Fatalf("first=%v last=%v", first, last) + } +} + +func TestCalendarSlotsUSUsesLocalWeekday(t *testing.T) { + la := mustLoc(t, "America/Los_Angeles") + // October: PDT (UTC-7). 09:00 IST Monday = 20:30 Sunday PDT; + // 16:00 IST Friday = 03:30 Friday PDT. + slots := parseSlots(t, calendarSlots(time.Date(2026, 10, 7, 12, 0, 0, 0, la), la)) + if slots[0] != (slot{0, 20, 30}) { + t.Errorf("first slot = %v, want Sunday 20:30", slots[0]) + } + if got := slots[len(slots)-1]; got != (slot{5, 3, 30}) { + t.Errorf("last slot = %v, want Friday 03:30", got) + } + // January: PST (UTC-8) — schedule follows the current offset. + slots = parseSlots(t, calendarSlots(time.Date(2026, 1, 7, 12, 0, 0, 0, la), la)) + if slots[0] != (slot{0, 19, 30}) { + t.Errorf("winter first slot = %v, want Sunday 19:30", slots[0]) + } +} + +func TestTruncIsRuneSafeAndStripsControls(t *testing.T) { + got := trunc("₹₹₹₹₹₹", 4) + if !utf8.ValidString(got) || got != "₹₹₹…" { + t.Errorf("trunc = %q", got) + } + if got := trunc("a\x1b]0;pwn\x07b", 40); got != "a ]0;pwn b" { + t.Errorf("trunc did not strip controls: %q", got) + } +} diff --git a/cmd/indw/menubar.go b/cmd/indw/menubar.go index d300a2f..ab2df98 100644 --- a/cmd/indw/menubar.go +++ b/cmd/indw/menubar.go @@ -7,8 +7,11 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strings" + "unicode" + "unicode/utf8" "github.com/abinashstack/indmoney-watch/internal/config" "github.com/abinashstack/indmoney-watch/internal/indmoney" @@ -88,11 +91,12 @@ func menubarRender(ctx context.Context) error { fmt.Printf("By asset class | color=%s\n", mb.HeaderColor) for _, inv := range snap.Investments { arrow := arrowFor(inv.ReturnPercentage) - fmt.Printf(" %s %s ₹%s %+.2f%% | color=%s font=Menlo\n", + line := fmt.Sprintf(" %s %s ₹%s %+.2f%%", arrow, padRight(inv.AssetType, 11), commaINR(inv.CurrentValue), - inv.ReturnPercentage, pickColor(mb, inv.ReturnPercentage, false)) + inv.ReturnPercentage) + fmt.Printf("%s | color=%s font=Menlo\n", sbEscape(line), pickColor(mb, inv.ReturnPercentage, false)) } } @@ -359,21 +363,25 @@ func menubarInstall() error { return err } - // Refresh cadence comes from config (e.g. "10m", "1s", "30s", "1h"). + // Refresh cadence comes from config (e.g. "10m", "1s", "30s", "1h"). It + // becomes part of the plugin filename, so it must be strictly validated: + // a value like "../../x" would otherwise write the script elsewhere. cfg, _ := config.Load() cadence := "10m" if cfg != nil && cfg.Menubar.RefreshInterval != "" { cadence = cfg.Menubar.RefreshInterval } + if !validCadence(cadence) { + return fmt.Errorf("menubar.refresh_interval %q is invalid: use a number followed by s, m, h or d (e.g. 10m)", cadence) + } pluginPath := filepath.Join(pluginsDir, "indmoney."+cadence+".sh") script := fmt.Sprintf(`#!/bin/bash # INDmoney # 0.1 # indmoney-watch # Portfolio + watchlist via INDmoney MCP -# [INDW_BIN=%s] -exec "%s" menubar -`, exe, exe) +exec %s menubar +`, shellQuote(exe)) // 0700: owner read/write/execute only. SwiftBar plugin scripts run with // your indw binary on a timer, which means anyone who can write to this @@ -403,7 +411,7 @@ exec "%s" menubar } else { fmt.Println("SwiftBar is installed. Either launch it or run:") fmt.Println(" open -a SwiftBar") - _ = exec.Command("open", "-a", "SwiftBar").Start() + _ = exec.Command("/usr/bin/open", "-a", "SwiftBar").Start() } return nil } @@ -455,24 +463,51 @@ func commaINR(v float64) string { return out } +// padRight pads s with spaces to n characters (runes, not bytes, so names +// containing ₹ or other multi-byte characters still line up). func padRight(s string, n int) string { - if len(s) >= n { + l := utf8.RuneCountInString(s) + if l >= n { return s } - return s + strings.Repeat(" ", n-len(s)) + return s + strings.Repeat(" ", n-l) } -// sbEscape escapes characters SwiftBar treats specially in line text. +// sbEscape neutralises characters SwiftBar treats specially in line text. +// Text from INDmoney (names, asset types, error bodies) flows through here, so +// it must not be able to start a new line or add attributes: a `|` would let +// it append `bash=…` (command execution on click), and a newline would let it +// emit an entirely new menu line. Control characters are replaced with spaces. func sbEscape(s string) string { - s = strings.ReplaceAll(s, "|", "¦") - return s + return strings.Map(func(r rune) rune { + switch { + case r == '|': + return '¦' + case unicode.IsControl(r): + return ' ' + } + return r + }, s) +} + +var cadenceRe = regexp.MustCompile(`^[1-9][0-9]{0,4}[smhd]$`) + +// validCadence reports whether v is a SwiftBar refresh interval that is safe +// to embed in a filename. +func validCadence(v string) bool { + return cadenceRe.MatchString(v) +} + +// shellQuote single-quotes s for safe interpolation into a bash script. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // swiftbarPluginsDir reads SwiftBar's user-configured plugin directory from its // `defaults` store. Returns "" if not set. SwiftBar's bundle id is // `com.ameba.SwiftBar` and the key is `PluginDirectory`. func swiftbarPluginsDir() string { - out, err := exec.Command("defaults", "read", "com.ameba.SwiftBar", "PluginDirectory").Output() + out, err := exec.Command("/usr/bin/defaults", "read", "com.ameba.SwiftBar", "PluginDirectory").Output() if err != nil { return "" } diff --git a/cmd/indw/menubar_test.go b/cmd/indw/menubar_test.go new file mode 100644 index 0000000..c2498c1 --- /dev/null +++ b/cmd/indw/menubar_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "os/exec" + "strings" + "testing" +) + +func TestSbEscapeBlocksLineAndAttributeInjection(t *testing.T) { + in := "Stocks\nClick me | bash=/bin/sh param1=-c param2=id terminal=false\r--sub" + got := sbEscape(in) + if strings.ContainsAny(got, "|\n\r") { + t.Fatalf("sbEscape left a separator in %q", got) + } + if !strings.Contains(got, "¦ bash=") { + t.Fatalf("expected pipe to be replaced, got %q", got) + } +} + +func TestValidCadence(t *testing.T) { + for _, ok := range []string{"10m", "1s", "30s", "1h", "2d"} { + if !validCadence(ok) { + t.Errorf("validCadence(%q) = false, want true", ok) + } + } + for _, bad := range []string{"", "0m", "10", "m", "10m/../x", "../../x", "10m.sh", "1 m", "10M"} { + if validCadence(bad) { + t.Errorf("validCadence(%q) = true, want false", bad) + } + } +} + +func TestShellQuoteRoundTrips(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + for _, s := range []string{ + "/usr/local/bin/indw", + "/Users/a b/bin/indw", + `/tmp/$(touch pwned)/it's "quoted"/` + "`id`", + } { + out, err := exec.Command("bash", "-c", "printf %s "+shellQuote(s)).Output() + if err != nil { + t.Fatalf("bash: %v", err) + } + if string(out) != s { + t.Errorf("shellQuote(%q) round-tripped to %q", s, out) + } + } +} + +func TestPadRightCountsRunes(t *testing.T) { + if got := padRight("₹ab", 5); got != "₹ab " { + t.Errorf("padRight = %q", got) + } +} diff --git a/internal/alert/engine.go b/internal/alert/engine.go index ee58d86..8cdbb6f 100644 --- a/internal/alert/engine.go +++ b/internal/alert/engine.go @@ -120,11 +120,10 @@ func (e *Engine) Run(ctx context.Context) error { if cc.DueDate == "" { continue } - due, err := time.Parse("2006-01-02", cc.DueDate) + daysLeft, err := daysUntil(cc.DueDate, now) if err != nil { continue } - daysLeft := int(math.Floor(time.Until(due).Hours() / 24)) if daysLeft >= 0 && daysLeft <= e.cfg.CreditCardDueWarningDays { e.fire(now, "cc-due:"+cc.Name+":"+cc.DueDate, fmt.Sprintf("%s due in %dd", cc.Name, daysLeft), @@ -140,6 +139,7 @@ func (e *Engine) Run(ctx context.Context) error { e.checkSIPs(ctx, now) } + e.pruneLastFired(now) e.st.LastSnapshot = now return state.Save(e.st) } @@ -155,7 +155,12 @@ func (e *Engine) checkIndianWatchlist(ctx context.Context, now time.Time) { } indKeys := make([]string, 0, len(stocks)) for _, s := range stocks { - indKeys = append(indKeys, s.IndKey) + if s.IndKey != "" { + indKeys = append(indKeys, s.IndKey) + } + } + if len(indKeys) == 0 { + return } details, err := e.api.IndianStockDetails(ctx, indKeys) if err != nil { @@ -280,8 +285,7 @@ func (e *Engine) applySIPs(now time.Time, kind string, sips []indmoney.SIP) { // 3. Due-soon — fire when next_execution_date is within the window. // Date-scoped alert key so it fires once per installment cycle. if e.cfg.SIPDueWarningDays > 0 && nextDate != "" { - if due, err := time.Parse("2006-01-02", nextDate); err == nil { - daysLeft := int(math.Floor(time.Until(due).Hours() / 24)) + if daysLeft, err := daysUntil(nextDate, now); err == nil { if daysLeft >= 0 && daysLeft <= e.cfg.SIPDueWarningDays { e.fire(now, "sip-due:"+key+":"+nextDate, fmt.Sprintf("SIP %s due in %dd", name, daysLeft), @@ -344,6 +348,41 @@ func (e *Engine) fire(now time.Time, key, title, subtitle, msg string) { } } +// daysUntil returns the number of calendar days from now's local date to +// date (YYYY-MM-DD): 0 means today, 1 tomorrow, negative means past. +// +// Both sides are compared as local calendar dates. Parsing with time.Parse +// would yield midnight UTC (05:30 IST), making a bill due tomorrow read as +// "due in 0d" and one due today read as overdue during market hours. +func daysUntil(date string, now time.Time) (int, error) { + loc := now.Location() + due, err := time.ParseInLocation("2006-01-02", date, loc) + if err != nil { + return 0, err + } + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + // Round, not truncate: a DST transition makes a calendar day 23 or 25 h. + return int(math.Round(due.Sub(today).Hours() / 24)), nil +} + +// lastFiredRetention bounds how long debounce entries are kept. Keys such as +// cc-due:: and sip-due:: are unique per cycle, so +// without pruning state.json grows forever. Anything older than the largest +// window we care about (debounce, or the 6 h needs-login cooldown) is dead. +const lastFiredRetention = 30 * 24 * time.Hour + +func (e *Engine) pruneLastFired(now time.Time) { + keep := lastFiredRetention + if d := time.Duration(e.cfg.DebounceMinutes) * time.Minute; d > keep { + keep = d + } + for k, t := range e.st.LastFired { + if now.Sub(t) > keep { + delete(e.st.LastFired, k) + } + } +} + func pctReturn(curr, inv float64) float64 { if inv == 0 { return 0 diff --git a/internal/alert/engine_test.go b/internal/alert/engine_test.go new file mode 100644 index 0000000..ad3ebc2 --- /dev/null +++ b/internal/alert/engine_test.go @@ -0,0 +1,47 @@ +package alert + +import ( + "testing" + "time" + + "github.com/abinashstack/indmoney-watch/internal/config" + "github.com/abinashstack/indmoney-watch/internal/state" +) + +func TestDaysUntilUsesLocalCalendarDates(t *testing.T) { + ist := time.FixedZone("IST", 5*3600+30*60) + now := time.Date(2026, 10, 4, 10, 0, 0, 0, ist) // market hours + cases := map[string]int{ + "2026-10-04": 0, // due today — previously -1 (never alerted) + "2026-10-05": 1, // due tomorrow — previously reported as 0 + "2026-10-07": 3, + "2026-10-03": -1, + } + for date, want := range cases { + got, err := daysUntil(date, now) + if err != nil { + t.Fatalf("daysUntil(%s): %v", date, err) + } + if got != want { + t.Errorf("daysUntil(%s) = %d, want %d", date, got, want) + } + } + if _, err := daysUntil("not-a-date", now); err == nil { + t.Error("expected parse error") + } +} + +func TestPruneLastFired(t *testing.T) { + now := time.Now() + st := state.New() + st.LastFired["old"] = now.Add(-31 * 24 * time.Hour) + st.LastFired["recent"] = now.Add(-time.Hour) + e := &Engine{cfg: config.Defaults(), st: st} + e.pruneLastFired(now) + if _, ok := st.LastFired["old"]; ok { + t.Error("old entry not pruned") + } + if _, ok := st.LastFired["recent"]; !ok { + t.Error("recent entry pruned") + } +} diff --git a/internal/indmoney/api.go b/internal/indmoney/api.go index 45d6c9b..1cda90f 100644 --- a/internal/indmoney/api.go +++ b/internal/indmoney/api.go @@ -115,17 +115,29 @@ type Watchlist struct { Watchlists []WatchlistGroup `json:"watchlists"` } -// AllStocks flattens entries across every named watchlist, deduplicating by -// ind_key (a stock can appear in multiple watchlists). +// AllStocks flattens entries across every named watchlist, deduplicating +// (a stock can appear in multiple watchlists). Entries are keyed by ind_key, +// falling back to ticker; an entry with neither is kept as-is. Keying on an +// empty ind_key would collapse every ticker-only entry (e.g. US stocks) into +// the first one. func (w *Watchlist) AllStocks() []WatchlistEntry { seen := map[string]bool{} var out []WatchlistEntry for _, g := range w.Watchlists { for _, s := range g.Stocks { - if seen[s.IndKey] { - continue + var key string + switch { + case s.IndKey != "": + key = "ind:" + s.IndKey + case s.Ticker != "": + key = "ticker:" + s.Ticker + } + if key != "" { + if seen[key] { + continue + } + seen[key] = true } - seen[s.IndKey] = true out = append(out, s) } } diff --git a/internal/indmoney/api_test.go b/internal/indmoney/api_test.go new file mode 100644 index 0000000..b184f59 --- /dev/null +++ b/internal/indmoney/api_test.go @@ -0,0 +1,14 @@ +package indmoney + +import "testing" + +func TestAllStocksKeepsTickerOnlyEntries(t *testing.T) { + w := &Watchlist{Watchlists: []WatchlistGroup{ + {Stocks: []WatchlistEntry{{Ticker: "AAPL"}, {Ticker: "MSFT"}, {IndKey: "INE1"}}}, + {Stocks: []WatchlistEntry{{Ticker: "AAPL"}, {IndKey: "INE1"}, {}}}, + }} + got := w.AllStocks() + if len(got) != 4 { // AAPL, MSFT, INE1, and the keyless entry + t.Fatalf("AllStocks returned %d entries: %+v", len(got), got) + } +} diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go index 3236b6c..8c87c2b 100644 --- a/internal/mcpclient/client.go +++ b/internal/mcpclient/client.go @@ -27,6 +27,17 @@ type TokenSource interface { AccessToken(ctx context.Context) (string, error) } +// ForceRefresher is optionally implemented by a TokenSource that can replace +// an access token the server rejected before its advertised expiry. +type ForceRefresher interface { + ForceRefresh(ctx context.Context) error +} + +// maxResponse caps how much of a single MCP response we buffer. Portfolio and +// watchlist payloads are tens of KB; 16 MiB bounds memory if the upstream +// misbehaves without truncating any realistic response. +const maxResponse = 16 << 20 + func New(endpoint string, ts TokenSource) *Client { return &Client{ endpoint: endpoint, @@ -163,6 +174,23 @@ func (c *Client) notify(ctx context.Context, method string, params any) error { } func (c *Client) do(ctx context.Context, body []byte) ([]byte, error) { + respBody, err := c.doOnce(ctx, body) + if err != ErrUnauthorized { + return respBody, err + } + // The token was rejected before its advertised expiry (revoked, or + // rotated by another process). Refresh once and retry. + fr, ok := c.tokenSource.(ForceRefresher) + if !ok { + return nil, err + } + if rerr := fr.ForceRefresh(ctx); rerr != nil { + return nil, fmt.Errorf("%w (refresh after 401: %w)", ErrUnauthorized, rerr) + } + return c.doOnce(ctx, body) +} + +func (c *Client) doOnce(ctx context.Context, body []byte) ([]byte, error) { tok, err := c.tokenSource.AccessToken(ctx) if err != nil { return nil, fmt.Errorf("get access token: %w", err) @@ -185,10 +213,13 @@ func (c *Client) do(ctx context.Context, body []byte) ([]byte, error) { if sid := resp.Header.Get("Mcp-Session-Id"); sid != "" { c.sessionID = sid } - respBody, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse+1)) if err != nil { return nil, err } + if len(respBody) > maxResponse { + return nil, fmt.Errorf("response exceeds %d bytes", maxResponse) + } if resp.StatusCode == 401 { return nil, ErrUnauthorized } diff --git a/internal/mcpclient/client_test.go b/internal/mcpclient/client_test.go new file mode 100644 index 0000000..280f499 --- /dev/null +++ b/internal/mcpclient/client_test.go @@ -0,0 +1,59 @@ +package mcpclient + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +type fakeTokens struct { + tok string + forced int + newToken string +} + +func (f *fakeTokens) AccessToken(context.Context) (string, error) { return f.tok, nil } +func (f *fakeTokens) ForceRefresh(context.Context) error { + f.forced++ + f.tok = f.newToken + return nil +} + +func TestRetriesOnceAfter401WithRefreshedToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fresh" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`) + })) + defer srv.Close() + + ts := &fakeTokens{tok: "revoked", newToken: "fresh"} + c := New(srv.URL, ts) + if _, err := c.Raw(context.Background(), "ping", nil); err != nil { + t.Fatalf("Raw: %v", err) + } + if ts.forced != 1 { + t.Fatalf("ForceRefresh called %d times, want 1", ts.forced) + } +} + +func TestPersistent401IsNotRetriedForever(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := New(srv.URL, &fakeTokens{tok: "a", newToken: "b"}) + if _, err := c.Raw(context.Background(), "ping", nil); err != ErrUnauthorized { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } + if calls != 2 { + t.Fatalf("server hit %d times, want 2", calls) + } +} diff --git a/internal/notify/macos.go b/internal/notify/macos.go index a8ffc41..61dd47d 100644 --- a/internal/notify/macos.go +++ b/internal/notify/macos.go @@ -21,7 +21,7 @@ func MacBanner(title, subtitle, message string) error { `subtitle (system attribute "INDW_SUBTITLE") ` + `sound name "Submarine"` - cmd := exec.Command("osascript", "-e", script) + cmd := exec.Command("/usr/bin/osascript", "-e", script) cmd.Env = append(cmd.Environ(), "INDW_TITLE="+title, "INDW_SUBTITLE="+subtitle, diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 59d452c..209509c 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -5,6 +5,7 @@ import ( "context" "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/json" "errors" @@ -15,6 +16,7 @@ import ( "net/http" "net/url" "os/exec" + "regexp" "strings" "sync" "time" @@ -40,6 +42,38 @@ const ( // OAuth endpoint while still letting users notice and Ctrl-C. var httpClient = &http.Client{Timeout: 30 * time.Second} +// maxBody caps how much of an OAuth endpoint response we read. Token and +// registration responses are a few hundred bytes; 1 MiB is generous while +// still bounding memory if the server misbehaves. +const maxBody = 1 << 20 + +// oauthErrCode matches a conservative subset of RFC 6749 error codes. Used +// to decide whether a server- or URL-supplied error string is safe to echo +// into logs (no newlines, no long attacker-controlled text). +var oauthErrCode = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`) + +// safeErrCode returns code if it looks like a plain OAuth error code, else a +// placeholder. Never return raw response bodies from these endpoints: they +// may contain tokens or client secrets, and errors end up in agent.log. +func safeErrCode(code string) string { + if oauthErrCode.MatchString(code) { + return code + } + return "unrecognized_error" +} + +// readOAuthError decodes the RFC 6749 §5.2 error code from a response body. +func readOAuthError(rb []byte) string { + var oe struct { + Error string `json:"error"` + } + _ = json.Unmarshal(rb, &oe) + if oe.Error == "" { + return "no error code" + } + return safeErrCode(oe.Error) +} + // ClientCreds is the result of dynamic client registration. type ClientCreds struct { ClientID string `json:"client_id"` @@ -73,16 +107,21 @@ func Register(ctx context.Context, redirectURI string) (*ClientCreds, error) { return nil, err } defer resp.Body.Close() - rb, _ := io.ReadAll(resp.Body) + rb, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return nil, fmt.Errorf("register read: %w", err) + } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("register http %d: %s", resp.StatusCode, string(rb)) + return nil, fmt.Errorf("register http %d: %s", resp.StatusCode, readOAuthError(rb)) } var c ClientCreds if err := json.Unmarshal(rb, &c); err != nil { - return nil, fmt.Errorf("register decode: %w", err) + // Don't wrap err: json syntax errors can quote body fragments, and the + // body may carry client_secret. + return nil, errors.New("register: malformed response") } if c.ClientID == "" { - return nil, fmt.Errorf("register: empty client_id (body=%s)", string(rb)) + return nil, errors.New("register: empty client_id") } return &c, nil } @@ -131,6 +170,7 @@ func Login(ctx context.Context, creds *ClientCreds, redirectURI string) (*Tokens resCh := make(chan result, 1) var once sync.Once srv := &http.Server{ + ReadHeaderTimeout: 10 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/callback" { http.NotFound(w, r) @@ -140,18 +180,23 @@ func Login(ctx context.Context, creds *ClientCreds, redirectURI string) (*Tokens code := r.URL.Query().Get("code") errParam := r.URL.Query().Get("error") w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Anyone who can navigate the user's browser (i.e. any web page) + // can hit this endpoint during the login window. Requests that + // don't carry our state are rejected WITHOUT ending the flow, so a + // forged ?error= or ?state= can't abort a legitimate login. + if subtle.ConstantTimeCompare([]byte(gotState), []byte(state)) != 1 { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, "

State mismatch

Ignored. Finish logging in from the original tab.

") + return + } if errParam != "" { - // errParam is attacker-controllable: anyone who can navigate the - // user's browser to http://127.0.0.1:47823/callback?error=… during - // the login window injects HTML otherwise. html.EscapeString covers - // the body context (& < > " '). _, _ = io.WriteString(w, "

Login failed

"+html.EscapeString(errParam)+"

") - once.Do(func() { resCh <- result{err: fmt.Errorf("oauth error: %s", errParam)} }) + once.Do(func() { resCh <- result{err: fmt.Errorf("oauth error: %s", safeErrCode(errParam))} }) return } - if gotState != state { - _, _ = io.WriteString(w, "

State mismatch

") - once.Do(func() { resCh <- result{err: fmt.Errorf("state mismatch")} }) + if code == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, "

Missing authorization code

") return } _, _ = io.WriteString(w, "

Logged in. You can close this tab.

") @@ -161,7 +206,7 @@ func Login(ctx context.Context, creds *ClientCreds, redirectURI string) (*Tokens go func() { _ = srv.Serve(ln) }() fmt.Printf("Opening browser for INDmoney login…\nIf it doesn't open, visit:\n %s\n\n", authURL) - _ = exec.Command("open", authURL).Start() + _ = exec.Command("/usr/bin/open", authURL).Start() var got result select { @@ -230,25 +275,21 @@ func tokenRequest(ctx context.Context, form url.Values) (*Tokens, error) { return nil, err } defer resp.Body.Close() - rb, _ := io.ReadAll(resp.Body) + rb, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return nil, fmt.Errorf("token read: %w", err) + } if resp.StatusCode >= 400 { - // Try to parse the standard OAuth error response (RFC 6749 §5.2). If - // the server says invalid_grant, the refresh token is dead and silent + // Parse the standard OAuth error response (RFC 6749 §5.2). If the + // server says invalid_grant, the refresh token is dead and silent // recovery isn't possible — wrap ErrNeedsLogin so callers can match - // via errors.Is and trigger a re-login flow. - var oe struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description"` + // via errors.Is and trigger a re-login flow. Only the error code is + // surfaced; the body is never echoed because it ends up in agent.log. + code := readOAuthError(rb) + if code == "invalid_grant" { + return nil, fmt.Errorf("token http %d: %s: %w", resp.StatusCode, code, ErrNeedsLogin) } - _ = json.Unmarshal(rb, &oe) - if oe.Error == "invalid_grant" { - desc := oe.ErrorDescription - if desc == "" { - desc = "refresh token rejected" - } - return nil, fmt.Errorf("token http %d: %s: %w", resp.StatusCode, desc, ErrNeedsLogin) - } - return nil, fmt.Errorf("token http %d: %s", resp.StatusCode, string(rb)) + return nil, fmt.Errorf("token http %d: %s", resp.StatusCode, code) } var raw struct { AccessToken string `json:"access_token"` @@ -258,7 +299,15 @@ func tokenRequest(ctx context.Context, form url.Values) (*Tokens, error) { TokenType string `json:"token_type"` } if err := json.Unmarshal(rb, &raw); err != nil { - return nil, fmt.Errorf("token decode: %w (body=%s)", err, string(rb)) + // Never include the body or the decoder error: a successful token + // response carries access_token and refresh_token. + return nil, errors.New("token: malformed response") + } + if raw.AccessToken == "" { + return nil, errors.New("token: empty access_token") + } + if raw.TokenType != "" && !strings.EqualFold(raw.TokenType, "bearer") { + return nil, fmt.Errorf("token: unsupported token_type %q", safeErrCode(raw.TokenType)) } return &Tokens{ AccessToken: raw.AccessToken, diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go new file mode 100644 index 0000000..6b98b22 --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,17 @@ +package oauth + +import "testing" + +func TestReadOAuthErrorNeverEchoesBody(t *testing.T) { + cases := map[string]string{ + `{"error":"invalid_grant","error_description":"refresh_token=SECRET"}`: "invalid_grant", + `{"access_token":"SECRET"}`: "no error code", + `not json SECRET`: "no error code", + `{"error":"bad\nSECRET \nlog-forge"), nil)) + if strings.Contains(rec.Body.String(), "