Skip to content

Forget idle scanner IPs in the brute-force detector - #71

Merged
eliau2005 merged 3 commits into
eliau2005:mainfrom
MrBeldum:fix/bound-ssh-ip-state
Oct 7, 2026
Merged

eliau2005 merged 3 commits into
eliau2005:mainfrom
MrBeldum:fix/bound-ssh-ip-state

Conversation

@MrBeldum

@MrBeldum MrBeldum commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

What & why

This handles the sshwatch part of #37: per-IP state that grows without limit on an internet-facing host.

BruteDetector

  • Before: Record trimmed old timestamps inside each IP's slice but never deleted the IP, and nothing ever pruned fired. Every scanner IP that failed once stayed in memory for the life of the process. The agent runs for months under MemoryMax=128M, so on a bare VPS that is slow, unbounded heap growth.
  • Now: Record sweeps at most once per Window and drops every IP whose newest attempt has left the window, along with its fired entry.
  • This can't change an alert decision. Those attempts would be filtered out on that IP's next Record anyway. fired is never later than the newest attempt, so a forgotten IP is already outside its quiet period and may fire again, exactly as before.
  • Memory is now bounded by the IPs seen in roughly the last two windows, not by every IP seen since start.
  • The sweep is amortised inside Record, so the agent loop needs no new goroutine or wiring.

GeoResolver

  • The cache is capped at 1024 entries and starts over when full. Lookups only happen for successful logins and live sessions, so this is far above what a real host uses. A cache miss costs one extra lookup.

Left for a separate PR: pruning alert.Engine's lastFired/active/streak maps. Event takes its cooldown per call, so expiring those keys safely needs a small design choice (store the expiry with the key, or move per-IP dedupe into the detector). I didn't want to bundle that here.

Refs #37

How it was tested

  • TestBruteDetectorForgetsIdleIPs records 5,000 scanner IPs plus one IP that crosses the threshold. One new attempt an hour later leaves 1 tracked IP and 0 fired entries. Without the fix the same test reports 5002 and 1. It then checks that the forgotten attacker fires again on a new burst.
  • TestBruteDetectorKeepsActiveIPs runs a sweep while an IP is mid-burst and checks that it still fires on its third attempt. It then runs another sweep during the quiet period and checks that the ongoing attack doesn't fire again.
  • TestGeoResolverCacheIsBounded runs 1,034 lookups against an httptest server and checks the cache stays at or under the cap.
  • The existing TestBruteDetector passes unchanged.
  • go test -race ./..., go vet ./... and gofmt -l . are clean.

Checklist

  • go test ./... passes
  • go vet ./... is clean
  • Code is gofmt-formatted
  • New behavior is covered by tests (and fixes include a regression test)
  • Docs updated where behavior, commands, config, or the security surface changed. No user-visible behavior changes.
  • The change keeps the agent featherweight and respects the OS-abstraction boundary

Summary by CodeRabbit

  • Bug Fixes
    • SSH activity tracking now clears outdated detection data while retaining recent activity, helping ensure fresh bursts are evaluated correctly.
    • Geographic lookup caching is limited to prevent it from growing without bound.

BruteDetector pruned timestamps inside each IP's slice but never deleted
the IP, and never pruned fired at all. On an internet-facing host every
scanner IP that ever failed once stayed in memory for the life of the
process, which runs for months under MemoryMax=128M.

Record now sweeps at most once per Window and drops every IP whose newest
attempt has left the window, together with its fired time. That cannot
change a decision: those attempts would be filtered out on the next
Record anyway, and fired is never later than the newest attempt, so the
IP is already outside its quiet period.

GeoResolver's cache is also capped at 1024 entries, and starts over when
full.

The alert engine's per-key maps are left for a separate change.

Refs eliau2005#37
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Repository: eliau2005/statixagent/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 551f716e-e112-434b-a4a3-df7c461edc63

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: eliau2005/statixagent/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b0e6c87e-343b-4db8-b6b5-0037a8800bc6
📥 Commits

Reviewing files that changed from the base of the PR and between 639f64a and e0c763d.

📒 Files selected for processing (3)
  • internal/sshwatch/brute.go
  • internal/sshwatch/geoip.go
  • internal/sshwatch/sshwatch_test.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The SSH watcher now periodically removes stale brute-detector state and limits the GeoResolver lookup cache to 1,024 entries. Tests cover state removal and retention, repeat detection after stale state is removed, and cache size.

Changes

Brute Detector State

Layer / File(s) Summary
Periodic brute-detector pruning
internal/sshwatch/brute.go, internal/sshwatch/sshwatch_test.go
Record runs a sweep when at least Window has passed since the previous sweep. The sweep removes stale attempt and fired state. Tests cover stale-state removal, fresh repeat detection, and retention of active state during the quiet period.

GeoResolver Cache Bound

Layer / File(s) Summary
Bounded lookup cache
internal/sshwatch/geoip.go, internal/sshwatch/sshwatch_test.go
GeoResolver clears its cache before inserting a result when the cache has at least 1,024 entries. A test checks that the cache does not exceed that limit.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to e0c76

The state bounds appear mergeable after normal checks; no outstanding issue is established.

Security Architecture Review

Security architecture risk: 🔵 Low · up to e0c76

The changes reduce accumulated memory without adding privileges or new access paths. The current event loop preserves detection behavior, but pruning introduces a timestamp-ordering assumption that could discard active evidence if the detector receives out-of-order calls.

Retained concerns

  • Low · reliability · inferred: Pruning assumes that the last appended timestamp is the newest attempt. Record does not enforce this ordering, so out-of-order calls can remove attempts still inside the detection window and delay threshold detection relative to the base implementation. The current production caller is serialized and supplies locally generated timestamps, limiting this to a detector-contract risk rather than a demonstrated attacker-controlled bypass.
Security review details

Security Blast Radius

  • observed — The directly affected security outcome is the monitored host's brute-force alerting. Failed-authentication events reach detector state, but the caller supplies their processing timestamp locally; remote log content does not select the timestamp passed to Record.

Security Findings and Attack Paths

  • inferred — The out-of-order pruning counterexample weakens the detector's input contract, but the identified production path does not expose timestamp ordering to an attacker. No attacker-reachable detection bypass was established in that path.

Trust Boundaries and Controls

  • observed — Geolocation is enrichment for successful-login alerts and session displays, not an authorization input. Session-disconnect targeting uses stored session identity and TTY validation rather than geolocation results. Cache eviction does not grant new command authority.

Resilience and Maintainability Implications

  • observed — Detector pruning and recording execute under one mutex. Geolocation clearing and insertion likewise share a mutex, preserving the entry cap under concurrent completions. Request, transport, and response-validation failures return empty enrichment before cache mutation; they do not replace detection or authorization decisions.

Hardening Proposals

  • proposed — Before supporting concurrent or replayed detector inputs, enforce chronological timestamps or determine the maximum retained timestamp during pruning. This would make the detection invariant independent of the current serialized caller.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: the brute-force detector forgets idle scanner IPs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Oct 5, 2026

Copy link
Copy Markdown

Add Carrot credits or activate Agent usage billing to use Autopilot

@eliau2005

Copy link
Copy Markdown
Owner

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@eliau2005 eliau2005 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The pruning logic is correct for the current caller, and the tests are good. There's one thing I'd like changed before merging.

prune treats times[len(times)-1] as the IP's newest attempt. That only holds while Record is called in chronological order. Today it is: the only caller passes time.Now() serially from authLoop. But Record takes an arbitrary at and doesn't enforce ordering. If an older timestamp is ever appended after a newer one (replayed or backfilled log lines, a future concurrent caller, tests), the sweep can drop an IP that still has attempts inside the window. It also drops that IP's fired entry, which can delay a threshold alert or allow an early re-fire. Both are changes from the pre-PR behaviour that the doc comment says can't happen.

Could you make prune independent of ordering? Either:

  • compute the max timestamp in the slice instead of taking the last element (cheap, since slices are at most about Threshold long), or
  • clamp in Record so attempts[ip] stays sorted.

The first option is the smaller change. A small test that records an out-of-order timestamp across a sweep would lock it in.

Minor, optional: the second loop in prune (deleting fired entries with no attempts) can't find anything, since fired[ip] is only set after attempts[ip]. Feel free to drop it.

MrBeldum and others added 2 commits October 6, 2026 09:18
Record does not require chronological order, so prune must use the max
timestamp rather than the last slice element. Drop the redundant fired
cleanup loop. Add an out-of-order sweep regression test.

@eliau2005 eliau2005 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Max-timestamp pruning plus the out-of-order regression test is exactly what I was after. I pushed a one-line gofmt fix (a stray blank line in the test file) to your branch, so nothing more is needed from you.

@eliau2005

Copy link
Copy Markdown
Owner

Thanks a lot for this, @MrBeldum! It's a clean fix with a clear write-up, and you turned the review around quickly. I really appreciate the contribution. If you find StatixAgent useful, a ⭐ on the repo would mean a lot and helps others find it.

@eliau2005
eliau2005 merged commit bdb6650 into eliau2005:main Oct 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants