From e584217553d1d2aca4a9c03d1203431fad41c3ff Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 00:37:59 +0530 Subject: [PATCH 1/8] fix: ScanDir blocked by its own SSRF protection ScanDir serves the target directory on an ephemeral 127.0.0.1 listener and crawls it through the crawler's private-IP blocking, which is enabled by default. Default-options scans (including the MCP inspect_scan_dir tool) therefore never fetched a page and returned a silently empty report; tests only passed by opting out with WithAllowPrivateIPs. Add a narrowly scoped private-IP allowlist to crawler.Config (PrivateIPAllowlist: exact host:port entries) honored at both SSRF layers - the transport dialer and validateURL - and have ScanDir register its listener address for the duration of that scan only. User-supplied URLs and all other private addresses stay blocked; entries are exact matches, so a different port on the same host is still rejected. Regression tests: ScanDir with default options now analyzes the local page, a loopback server not in the allowlist is still rejected, and crawler-level tests cover both the validateURL and dialer layers. --- CHANGELOG.md | 15 ++++ inspect_test.go | 66 ++++++++++++++++ internal/crawler/crawler.go | 83 ++++++++++++++------ internal/crawler/crawler_more_test.go | 104 ++++++++++++++++++++++++++ scanner.go | 46 ++++++++---- 5 files changed, 274 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a46c8..ca51f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [Unreleased] + +### Fixed +- **`Scanner.ScanDir` was blocked by its own SSRF protection.** The + temporary file server behind `ScanDir` listens on `127.0.0.1`, which the + crawler rejects under the default configuration, so default-options + scans (including the MCP `inspect_scan_dir` tool) returned a silently + empty report. The crawler now accepts an exact `host:port` private-IP + allowlist (`crawler.Config.PrivateIPAllowlist`) honored at both the + dialer and URL-validation layers, and `ScanDir` registers its ephemeral + listener address for the duration of that scan only. User-supplied URLs + and all other private addresses remain blocked. + +--- + ## [0.1.3] - 2026-07-04 ### Changed diff --git a/inspect_test.go b/inspect_test.go index ab089a6..39db664 100644 --- a/inspect_test.go +++ b/inspect_test.go @@ -5,6 +5,9 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" "github.com/GrayCodeAI/inspect" @@ -216,3 +219,66 @@ func TestScan_Presets(t *testing.T) { } } } + +// TestScanDir_DefaultOptions is a regression test: ScanDir serves the +// directory on an ephemeral loopback listener and must exempt exactly that +// listener from the crawler's SSRF protection (enabled by default), so the +// scan works without WithAllowPrivateIPs. +func TestScanDir_DefaultOptions(t *testing.T) { + dir := t.TempDir() + html := `Local

Hello

` + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(html), 0o644); err != nil { + t.Fatal(err) + } + + s := inspect.NewScanner() // default options: SSRF protection ON, no WithAllowPrivateIPs + report, err := s.ScanDir(context.Background(), dir) + if err != nil { + t.Fatalf("ScanDir failed with default options: %v", err) + } + if report.CrawledURLs < 1 { + t.Fatalf("expected at least 1 crawled URL, got %d", report.CrawledURLs) + } + + // The page must actually have been fetched and analyzed. Before the + // fix, SSRF protection rejected the scanner's own file server and the + // report came back silently empty. The img without an alt attribute + // produces a deterministic a11y finding that requires a parsed body. + hasAltFinding := false + for _, f := range report.Findings { + if f.Check == "a11y" && strings.Contains(f.Message, "alt") { + hasAltFinding = true + } + } + if !hasAltFinding { + t.Errorf("expected the local page to be analyzed (image alt finding); got %d findings — scan likely blocked by its own SSRF guard", len(report.Findings)) + } +} + +// TestScan_LoopbackNotAllowlistedStillBlocked asserts the flip side of the +// ScanDir exemption: a loopback server that is NOT registered in the +// crawl's private-IP allowlist stays rejected under default options. +func TestScan_LoopbackNotAllowlistedStillBlocked(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `X

X

`) + })) + defer srv.Close() + + s := inspect.NewScanner(inspect.Quick) // default SSRF protection, no WithAllowPrivateIPs + report, err := s.Scan(context.Background(), srv.URL) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + + // Every page fetch is rejected by SSRF protection, so no check can + // analyze page content and the report must carry no findings. (When + // the page is fetched, the Quick preset reports the broken img link, + // and the full check set reports missing headers/meta — so findings + // would be non-empty on a successful fetch.) + if len(report.Findings) != 0 { + for _, f := range report.Findings { + t.Errorf("unexpected finding from blocked scan: [%s] %s", f.Check, f.Message) + } + } +} diff --git a/internal/crawler/crawler.go b/internal/crawler/crawler.go index 6091b50..fbf0872 100644 --- a/internal/crawler/crawler.go +++ b/internal/crawler/crawler.go @@ -37,6 +37,16 @@ type Config struct { Logger *slog.Logger MaxPages int // Maximum number of pages to crawl; 0 means no limit + // PrivateIPAllowlist lists exact "host:port" addresses that are exempt + // from SSRF private-IP blocking at both the dialer and URL-validation + // layers. It exists so a caller that crawls a server it controls (e.g. + // the temporary file server behind Scanner.ScanDir) is not blocked by + // its own SSRF protection. Entries must carry an explicit port, apply + // to that address only, and live for the lifetime of this Config (one + // crawl). User-supplied URLs are unaffected: every other private + // address is still rejected. + PrivateIPAllowlist []string + // CircuitBreaker, when non-nil, gates requests per host. If a host has // accumulated too many consecutive failures the breaker opens and the // crawler skips further requests to that host until the cooldown expires. @@ -103,12 +113,13 @@ type FormInput struct { // Crawler performs concurrent crawling with rate limiting. type Crawler struct { - cfg Config - client *http.Client - seen map[string]bool - mu sync.Mutex - robots *RobotsCache - limiter *rateLimiter + cfg Config + client *http.Client + seen map[string]bool + mu sync.Mutex + robots *RobotsCache + limiter *rateLimiter + privateAllowed map[string]struct{} } // New creates a configured Crawler. @@ -128,23 +139,33 @@ func New(cfg Config) *Crawler { KeepAlive: 30 * time.Second, } + // Exact host:port addresses exempt from private-IP blocking (see + // Config.PrivateIPAllowlist). Shared by the dialer below and + // validateURL so both layers honor the same exemptions. + privateAllowed := make(map[string]struct{}, len(cfg.PrivateIPAllowlist)) + for _, hp := range cfg.PrivateIPAllowlist { + privateAllowed[hp] = struct{}{} + } + transport := &http.Transport{ MaxIdleConns: cfg.Concurrency * 2, MaxIdleConnsPerHost: cfg.Concurrency, IdleConnTimeout: 90 * time.Second, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { if !cfg.AllowPrivateIPs { - host, _, err := net.SplitHostPort(addr) - if err != nil { - return nil, err - } - ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil { - return nil, err - } - for _, ip := range ips { - if isPrivateIP(ip.IP) { - return nil, fmt.Errorf("SSRF protection: blocked connection to private IP %s", ip.IP) + if _, ok := privateAllowed[addr]; !ok { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + for _, ip := range ips { + if isPrivateIP(ip.IP) { + return nil, fmt.Errorf("SSRF protection: blocked connection to private IP %s", ip.IP) + } } } } @@ -165,11 +186,12 @@ func New(cfg Config) *Crawler { } return &Crawler{ - cfg: cfg, - client: client, - seen: make(map[string]bool), - robots: NewRobotsCache(), - limiter: newRateLimiter(cfg.RateLimit), + cfg: cfg, + client: client, + seen: make(map[string]bool), + robots: NewRobotsCache(), + limiter: newRateLimiter(cfg.RateLimit), + privateAllowed: privateAllowed, } } @@ -526,7 +548,8 @@ func (c *Crawler) noRedirectClient() *http.Client { } // validateURL checks the URL for SSRF risks: scheme must be http/https and -// resolved IP must not be in private ranges (unless AllowPrivateIPs is set). +// resolved IP must not be in private ranges (unless AllowPrivateIPs is set +// or the URL's host:port is on the crawl's private-IP allowlist). func (c *Crawler) validateURL(rawURL string) error { parsed, err := url.Parse(rawURL) if err != nil { @@ -535,7 +558,7 @@ func (c *Crawler) validateURL(rawURL string) error { if parsed.Scheme != "http" && parsed.Scheme != "https" { return fmt.Errorf("disallowed URL scheme %q (only http/https allowed)", parsed.Scheme) } - if c.cfg.AllowPrivateIPs { + if c.cfg.AllowPrivateIPs || c.privateAddrAllowed(parsed.Host) { return nil } host := parsed.Hostname() @@ -592,6 +615,18 @@ func isPrivateIP(ip net.IP) bool { return false } +// privateAddrAllowed reports whether hostport is an exact-match entry of +// this crawl's private-IP allowlist (Config.PrivateIPAllowlist). Entries +// are "host:port" strings for servers the caller controls; they are exempt +// from private-IP blocking while every other address stays protected. +func (c *Crawler) privateAddrAllowed(hostport string) bool { + if len(c.privateAllowed) == 0 { + return false + } + _, ok := c.privateAllowed[hostport] + return ok +} + func isRetryable(statusCode int) bool { return statusCode == 429 || statusCode == 500 || statusCode == 502 || statusCode == 503 || statusCode == 504 || statusCode == 0 diff --git a/internal/crawler/crawler_more_test.go b/internal/crawler/crawler_more_test.go index bd261be..b4ef4fe 100644 --- a/internal/crawler/crawler_more_test.go +++ b/internal/crawler/crawler_more_test.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -259,6 +260,109 @@ func TestValidateURL_SSRFProtection(t *testing.T) { } } +func TestValidateURL_PrivateIPAllowlist(t *testing.T) { + c := New(Config{ + AllowPrivateIPs: false, + PrivateIPAllowlist: []string{"127.0.0.1:8123"}, + UserAgent: "test", + }) + + // Exact allowlisted host:port is exempt from the private-IP check. + if err := c.validateURL("http://127.0.0.1:8123/index.html"); err != nil { + t.Errorf("expected allowlisted address to pass, got %v", err) + } + + // Same host, different port: not on the allowlist, must stay blocked. + if err := c.validateURL("http://127.0.0.1:8124/index.html"); err == nil { + t.Error("expected error for loopback address not in allowlist") + } + + // Host without an explicit port never matches (allowlist entries are + // exact host:port values), so it must stay blocked. + if err := c.validateURL("http://127.0.0.1/admin"); err == nil { + t.Error("expected error for loopback host without port not in allowlist") + } + + // Other private ranges remain blocked. + if err := c.validateURL("http://192.168.1.10:8123/"); err == nil { + t.Error("expected error for private range not in allowlist") + } +} + +func TestCrawl_PrivateIPAllowlist(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `

local

`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + parsed, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + hostPort := parsed.Host // 127.0.0.1: + + // Without an allowlist entry the loopback server must be rejected by + // SSRF protection (validateURL layer). + blocked := New(Config{UserAgent: "test"}) + pages, err := blocked.Crawl(context.Background(), srv.URL+"/") + if err != nil { + t.Fatalf("Crawl failed: %v", err) + } + if len(pages) != 1 || pages[0].Error == nil { + t.Fatalf("expected the crawl page to carry an error, got %+v", pages) + } + if !strings.Contains(pages[0].Error.Error(), "SSRF protection") { + t.Errorf("expected SSRF protection error, got %v", pages[0].Error) + } + + // The dialer layer must enforce the same rule for requests that skip + // validateURL (e.g. robots.txt and sitemap fetches). + _, dialErr := blocked.client.Get(srv.URL + "/") + if dialErr == nil || !strings.Contains(dialErr.Error(), "SSRF protection") { + t.Errorf("expected dialer to block loopback, got %v", dialErr) + } + + // With the server's exact host:port allowlisted for this crawl, both + // layers let the fetch through. + allowed := New(Config{ + UserAgent: "test", + PrivateIPAllowlist: []string{hostPort}, + }) + pages, err = allowed.Crawl(context.Background(), srv.URL+"/") + if err != nil { + t.Fatalf("Crawl failed: %v", err) + } + if len(pages) != 1 { + t.Fatalf("expected 1 page, got %d", len(pages)) + } + if pages[0].Error != nil { + t.Fatalf("expected allowlisted crawl to succeed, got %v", pages[0].Error) + } + if pages[0].StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", pages[0].StatusCode) + } + + // A different loopback port stays blocked even on the allowlisted + // crawler: spin up a second server and crawl it directly. + mux2 := http.NewServeMux() + mux2.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `

other

`) + }) + srv2 := httptest.NewServer(mux2) + defer srv2.Close() + pages, err = allowed.Crawl(context.Background(), srv2.URL+"/") + if err != nil { + t.Fatalf("Crawl failed: %v", err) + } + if len(pages) != 1 || pages[0].Error == nil || !strings.Contains(pages[0].Error.Error(), "SSRF protection") { + t.Errorf("expected second server to stay SSRF-blocked, got %+v", pages) + } +} + // --- isRetryable tests --- func TestIsRetryable(t *testing.T) { diff --git a/scanner.go b/scanner.go index c40a52b..18f1d60 100644 --- a/scanner.go +++ b/scanner.go @@ -28,6 +28,15 @@ func NewScanner(opts ...Option) *Scanner { // Scan crawls the target URL and runs all configured checks against the // discovered pages. Returns a complete Report with findings and stats. func (s *Scanner) Scan(ctx context.Context, target string) (*Report, error) { + return s.scan(ctx, target, nil) +} + +// scan is the shared implementation of Scan and ScanDir. allowPrivateAddrs +// lists exact host:port addresses exempt from the crawler's SSRF private-IP +// blocking for this scan only; ScanDir uses it for the ephemeral local file +// server it starts. It must only ever contain addresses the scanner itself +// brought up, never user-supplied input. +func (s *Scanner) scan(ctx context.Context, target string, allowPrivateAddrs []string) (*Report, error) { if s.cfg.timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, s.cfg.timeout) @@ -37,21 +46,22 @@ func (s *Scanner) Scan(ctx context.Context, target string) (*Report, error) { start := time.Now() crawlCfg := crawler.Config{ - MaxDepth: s.cfg.depth, - Concurrency: s.cfg.concurrency, - Timeout: s.cfg.timeout, - PageTimeout: s.cfg.pageTimeout, - RateLimit: s.cfg.rateLimit, - UserAgent: s.cfg.userAgent, - FollowRedirects: s.cfg.followRedirects, - RespectRobots: s.cfg.respectRobots, - Exclude: s.cfg.exclude, - AuthHeader: s.cfg.authHeader, - AuthValue: s.cfg.authValue, - CookieJar: s.cfg.cookieJar, - AllowPrivateIPs: !s.cfg.blockPrivateIPs, - Logger: s.cfg.logger, - MaxPages: s.cfg.maxPages, + MaxDepth: s.cfg.depth, + Concurrency: s.cfg.concurrency, + Timeout: s.cfg.timeout, + PageTimeout: s.cfg.pageTimeout, + RateLimit: s.cfg.rateLimit, + UserAgent: s.cfg.userAgent, + FollowRedirects: s.cfg.followRedirects, + RespectRobots: s.cfg.respectRobots, + Exclude: s.cfg.exclude, + AuthHeader: s.cfg.authHeader, + AuthValue: s.cfg.authValue, + CookieJar: s.cfg.cookieJar, + AllowPrivateIPs: !s.cfg.blockPrivateIPs, + PrivateIPAllowlist: allowPrivateAddrs, + Logger: s.cfg.logger, + MaxPages: s.cfg.maxPages, } if s.cfg.circuitBreakerOn { @@ -213,5 +223,9 @@ func (s *Scanner) ScanDir(ctx context.Context, dir string) (*Report, error) { return nil, err } defer srv.Close() - return s.Scan(ctx, "http://"+addr) + // The temporary file server listens on loopback, which the crawler's + // SSRF protection (enabled by default) would reject. Exempt exactly + // this listener address for the duration of the scan so ScanDir works + // with default options; every other private address stays blocked. + return s.scan(ctx, "http://"+addr, []string{addr}) } From 030bf127baea5d4b5b19c4d544fbba5f3a7b4149 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 00:40:10 +0530 Subject: [PATCH 2/8] fix: retain findings batch in FindingsStore.Flush on sink error Flush swapped the buffer out before calling StoreBatch, so any sink error silently dropped the whole batch. Re-queue the failed batch at the front of the buffer (entries added while StoreBatch ran stay behind it) and keep returning the wrapped error, so the next Flush retries the same entries instead of losing them. Keep the re-queue bounded: the buffer is capped at maxBufferEntries (10,000); overflow drops the oldest entries and counts them in the new FindingsStore.Dropped accessor. Tests: failed batch is retained and retried verbatim on the next Flush, re-queue ordering, and the bounded-overflow drop behavior. --- CHANGELOG.md | 8 +++ findings_store.go | 41 ++++++++++++++ findings_store_test.go | 125 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca51f15..f9afaa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] ### Fixed +- **`FindingsStore.Flush` dropped the batch when the sink errored.** The + buffer was swapped out before `StoreBatch`, so a failing sink silently + lost every buffered entry. A failed flush now re-queues its batch at + the front of the buffer (entries added in the meantime keep their + order behind it) and still returns the error, so the next `Flush` + retries the same entries. The buffer stays bounded: re-queued batches + are capped at `maxBufferEntries` (10,000); overflow drops the oldest + entries and is reported via the new `FindingsStore.Dropped` counter. - **`Scanner.ScanDir` was blocked by its own SSRF protection.** The temporary file server behind `ScanDir` listens on `127.0.0.1`, which the crawler rejects under the default configuration, so default-options diff --git a/findings_store.go b/findings_store.go index 328742c..2b3c87f 100644 --- a/findings_store.go +++ b/findings_store.go @@ -35,8 +35,15 @@ type FindingsStore struct { mu sync.Mutex batchSize int autoFlush bool + dropped int // entries dropped after failed flushes overflowed maxBufferEntries } +// maxBufferEntries bounds the buffer when a failed flush re-queues its +// batch: with a persistently failing sink the buffer would otherwise grow +// without limit. When the bound is exceeded the oldest entries are +// dropped (and counted via Dropped); the newest are kept. +const maxBufferEntries = 10000 + // FindingsStoreOption configures a FindingsStore. type FindingsStoreOption func(*FindingsStore) @@ -94,6 +101,10 @@ func (s *FindingsStore) Add(ctx context.Context, entry FindingEntry) error { // Flush sends all buffered entries to the sink and clears the buffer. // A nil sink causes Flush to clear the buffer without error. +// If the sink rejects the batch, the entries are put back at the front of +// the buffer so the next Flush retries them, and the error is returned. +// The buffer stays bounded: once it holds maxBufferEntries entries the +// oldest are dropped (see Dropped). func (s *FindingsStore) Flush(ctx context.Context) error { s.mu.Lock() if len(s.buffer) == 0 { @@ -109,11 +120,31 @@ func (s *FindingsStore) Flush(ctx context.Context) error { } if err := s.sink.StoreBatch(ctx, batch); err != nil { + // The sink failed; keep the batch buffered (in front of any + // entries added while StoreBatch was running) so the next + // Flush retries it instead of silently dropping it. + s.mu.Lock() + s.requeueLocked(batch) + s.mu.Unlock() return fmt.Errorf("inspect: flushing findings batch (%d entries): %w", len(batch), err) } return nil } +// requeueLocked prepends a failed batch to the buffer. The combined buffer +// is capped at maxBufferEntries; overflow drops the oldest entries and +// counts them in s.dropped. Caller must hold s.mu. +func (s *FindingsStore) requeueLocked(batch []FindingEntry) { + combined := make([]FindingEntry, 0, len(batch)+len(s.buffer)) + combined = append(combined, batch...) + combined = append(combined, s.buffer...) + if overflow := len(combined) - maxBufferEntries; overflow > 0 { + s.dropped += overflow + combined = combined[overflow:] + } + s.buffer = combined +} + // Size returns the number of entries currently in the buffer. func (s *FindingsStore) Size() int { s.mu.Lock() @@ -121,6 +152,16 @@ func (s *FindingsStore) Size() int { return len(s.buffer) } +// Dropped returns the number of entries discarded because failed flushes +// re-queued more entries than the buffer is allowed to hold +// (maxBufferEntries). It reports entries lost to a persistently failing +// sink and never resets. +func (s *FindingsStore) Dropped() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.dropped +} + // ConvertScanResult converts a slice of Finding records from a scan into // FindingEntry records for the store. func ConvertScanResult(url string, findings []Finding) []FindingEntry { diff --git a/findings_store_test.go b/findings_store_test.go index fabe1a7..06438d3 100644 --- a/findings_store_test.go +++ b/findings_store_test.go @@ -3,6 +3,7 @@ package inspect import ( "context" "errors" + "fmt" "sync" "sync/atomic" "testing" @@ -314,6 +315,130 @@ func TestFlushPropagatesBatchError(t *testing.T) { } } +// TestFlushRetriesBatchAfterSinkError verifies that a batch rejected by the +// sink is retained in the buffer and delivered by the next successful +// Flush, instead of being dropped. +func TestFlushRetriesBatchAfterSinkError(t *testing.T) { + sink := &mockFindingSink{batchErr: errors.New("sink down")} + store := NewFindingsStore(sink, WithAutoFlush(false)) + ctx := context.Background() + + for i := 0; i < 5; i++ { + if err := store.Add(ctx, FindingEntry{Message: fmt.Sprintf("entry-%d", i)}); err != nil { + t.Fatalf("Add failed: %v", err) + } + } + + if err := store.Flush(ctx); err == nil { + t.Fatal("expected error from Flush with failing sink") + } + if got := store.Size(); got != 5 { + t.Fatalf("expected failed batch of 5 entries to be retained, got buffer size %d", got) + } + + // Sink recovers: the next Flush must retry and deliver the same batch. + sink.mu.Lock() + sink.batchErr = nil + sink.mu.Unlock() + if err := store.Flush(ctx); err != nil { + t.Fatalf("Flush after sink recovery failed: %v", err) + } + if got := sink.batchCallCount(); got != 2 { + t.Errorf("expected 2 StoreBatch calls (failed + retry), got %d", got) + } + sink.mu.Lock() + retry := sink.batchCalls[len(sink.batchCalls)-1] + sink.mu.Unlock() + if len(retry) != 5 { + t.Fatalf("expected retry batch of 5 entries, got %d", len(retry)) + } + for i, e := range retry { + want := fmt.Sprintf("entry-%d", i) + if e.Message != want { + t.Errorf("retry batch entry %d: want %q, got %q", i, want, e.Message) + } + } + if got := store.Size(); got != 0 { + t.Errorf("expected empty buffer after successful retry, got %d", got) + } + if got := store.Dropped(); got != 0 { + t.Errorf("expected no dropped entries, got %d", got) + } +} + +// TestFlushRequeuesFailedBatchBeforeNewEntries verifies ordering: entries +// added while a flush is failing end up behind the re-queued batch. +func TestFlushRequeuesFailedBatchBeforeNewEntries(t *testing.T) { + sink := &mockFindingSink{batchErr: errors.New("sink down")} + store := NewFindingsStore(sink, WithAutoFlush(false)) + ctx := context.Background() + + _ = store.Add(ctx, FindingEntry{Message: "first"}) + _ = store.Add(ctx, FindingEntry{Message: "second"}) + if err := store.Flush(ctx); err == nil { + t.Fatal("expected error from Flush with failing sink") + } + _ = store.Add(ctx, FindingEntry{Message: "third"}) + + sink.mu.Lock() + sink.batchErr = nil + sink.mu.Unlock() + if err := store.Flush(ctx); err != nil { + t.Fatalf("Flush after sink recovery failed: %v", err) + } + sink.mu.Lock() + got := sink.batchCalls[len(sink.batchCalls)-1] + sink.mu.Unlock() + want := []string{"first", "second", "third"} + if len(got) != len(want) { + t.Fatalf("expected %d entries, got %d", len(want), len(got)) + } + for i, e := range got { + if e.Message != want[i] { + t.Errorf("entry %d: want %q, got %q", i, want[i], e.Message) + } + } +} + +// TestFlushRequeueIsBounded verifies the buffer cap when a failed batch is +// re-queued: overflow drops the oldest entries and counts them in Dropped. +func TestFlushRequeueIsBounded(t *testing.T) { + store := NewFindingsStore(&mockFindingSink{}, WithAutoFlush(false)) + + store.mu.Lock() + store.buffer = make([]FindingEntry, maxBufferEntries-1) + for i := range store.buffer { + store.buffer[i].Message = fmt.Sprintf("buffered-%d", i) + } + batch := make([]FindingEntry, 10) + for i := range batch { + batch[i].Message = fmt.Sprintf("batch-%d", i) + } + store.requeueLocked(batch) + size := len(store.buffer) + dropped := store.dropped + first, last := store.buffer[0].Message, store.buffer[len(store.buffer)-1].Message + store.mu.Unlock() + + if size != maxBufferEntries { + t.Errorf("expected buffer capped at %d entries, got %d", maxBufferEntries, size) + } + if dropped != 9 { + t.Errorf("expected 9 dropped entries, got %d", dropped) + } + if got := store.Dropped(); got != 9 { + t.Errorf("Dropped() = %d, want 9", got) + } + // The oldest entries (the head of the failed batch) are dropped; the + // newest (the tail of the existing buffer) survive. + if first != "batch-9" { + t.Errorf("first buffered entry = %q, want %q", first, "batch-9") + } + if last != fmt.Sprintf("buffered-%d", maxBufferEntries-2) { + t.Errorf("last buffered entry = %q, want the newest buffered entry", last) + } +} + // TestAutoFlushMultipleBatches verifies that auto-flush triggers correctly // across multiple batch boundaries. func TestAutoFlushMultipleBatches(t *testing.T) { From 251c6d71c61bcf4c3375cdb84c7eb83918dfd665 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 00:40:54 +0530 Subject: [PATCH 3/8] refactor: remove dead exported RateLimiter The exported RateLimiter (ratelimit.go) had zero non-test callers anywhere in inspect or the hawk workspace: the crawler rate-limits through its own internal per-crawl limiter (internal/crawler/rate.go). It also carried a latent bug - Close() panicked on double-Close. Delete ratelimit.go and ratelimit_test.go, and drop the now-unused golang.org/x/time dependency from go.mod. Crawler rate limiting is unchanged. --- CHANGELOG.md | 9 ++ go.mod | 1 - go.sum | 2 - ratelimit.go | 140 ------------------------ ratelimit_test.go | 263 ---------------------------------------------- 5 files changed, 9 insertions(+), 406 deletions(-) delete mode 100644 ratelimit.go delete mode 100644 ratelimit_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f9afaa9..cde5dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Removed +- **Dead exported `RateLimiter` type** (`ratelimit.go`). It had no + callers — the crawler rate-limits via its own internal per-crawl + limiter (`internal/crawler/rate.go`) — and its `Close` panicked when + called twice. Removed along with its test file; the now-unused + `golang.org/x/time` dependency is dropped from `go.mod`. This is a + minor API removal of code that never worked in a real scan; the + crawler's actual rate limiting is unchanged. + ### Fixed - **`FindingsStore.Flush` dropped the batch when the sink errored.** The buffer was swapped out before `StoreBatch`, so a failing sink silently diff --git a/go.mod b/go.mod index 26c8fd7..29da55d 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/GrayCodeAI/hawk-mcpkit v0.1.4 github.com/mark3labs/mcp-go v0.49.0 golang.org/x/net v0.55.0 - golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index e00a87b..1a25514 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,6 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/ratelimit.go b/ratelimit.go deleted file mode 100644 index 0349948..0000000 --- a/ratelimit.go +++ /dev/null @@ -1,140 +0,0 @@ -package inspect - -import ( - "context" - "sync" - "time" - - "golang.org/x/time/rate" -) - -// hostLimit pairs a per-host limiter with the time it was last accessed. -type hostLimit struct { - limiter *rate.Limiter - lastUsed time.Time -} - -// RateLimiter provides per-host rate limiting for crawl requests. Each host -// gets its own token-bucket limiter so that aggressive crawling of one host -// does not throttle requests to others. Stale limiters are cleaned up -// periodically. -type RateLimiter struct { - limits map[string]*hostLimit - mu sync.Mutex - requestsPerSecond float64 - burst int - cleanupInterval time.Duration - stopCleanup chan struct{} -} - -// RateLimiterOption is a functional option for configuring a RateLimiter. -type RateLimiterOption func(*RateLimiter) - -// WithCleanupInterval sets how often stale host limiters are reaped. -// A host is considered stale when it has not been used for 5 minutes. -// The default cleanup interval is 1 minute. -func WithCleanupInterval(d time.Duration) RateLimiterOption { - return func(rl *RateLimiter) { - rl.cleanupInterval = d - } -} - -// NewRateLimiter creates a per-host rate limiter that allows rps requests per -// second with the given burst size. Each host that is seen gets its own -// independent limiter. Background cleanup removes limiters for hosts that -// have not been accessed in 5 minutes. -func NewRateLimiter(rps float64, burst int, opts ...RateLimiterOption) *RateLimiter { - rl := &RateLimiter{ - limits: make(map[string]*hostLimit), - requestsPerSecond: rps, - burst: burst, - cleanupInterval: time.Minute, - stopCleanup: make(chan struct{}), - } - - for _, opt := range opts { - opt(rl) - } - - go rl.cleanupLoop() - - return rl -} - -// getOrCreate returns the limiter for host, creating one if it does not exist. -// Caller must hold rl.mu. -func (rl *RateLimiter) getOrCreate(host string) *hostLimit { - hl, ok := rl.limits[host] - if !ok { - hl = &hostLimit{ - limiter: rate.NewLimiter(rate.Limit(rl.requestsPerSecond), rl.burst), - } - rl.limits[host] = hl - } - hl.lastUsed = time.Now() - return hl -} - -// Wait blocks until a request for host is allowed or ctx is cancelled. -func (rl *RateLimiter) Wait(ctx context.Context, host string) error { - rl.mu.Lock() - hl := rl.getOrCreate(host) - rl.mu.Unlock() - - return hl.limiter.Wait(ctx) -} - -// Allow reports whether a request for host is allowed right now without -// blocking. Returns true if the request may proceed. -func (rl *RateLimiter) Allow(host string) bool { - rl.mu.Lock() - hl := rl.getOrCreate(host) - rl.mu.Unlock() - - return hl.limiter.Allow() -} - -// ActiveHosts returns the number of hosts currently being tracked. -func (rl *RateLimiter) ActiveHosts() int { - rl.mu.Lock() - defer rl.mu.Unlock() - - return len(rl.limits) -} - -// Close stops the background cleanup goroutine. After Close is called the -// limiter should not be used. -func (rl *RateLimiter) Close() { - close(rl.stopCleanup) -} - -// cleanupLoop periodically removes limiters for hosts that have not been -// accessed in the stale threshold (5 minutes). -func (rl *RateLimiter) cleanupLoop() { - ticker := time.NewTicker(rl.cleanupInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - rl.reapStale() - case <-rl.stopCleanup: - return - } - } -} - -const staleThreshold = 5 * time.Minute - -// reapStale removes host limiters that have not been used recently. -func (rl *RateLimiter) reapStale() { - rl.mu.Lock() - defer rl.mu.Unlock() - - now := time.Now() - for host, hl := range rl.limits { - if now.Sub(hl.lastUsed) > staleThreshold { - delete(rl.limits, host) - } - } -} diff --git a/ratelimit_test.go b/ratelimit_test.go deleted file mode 100644 index d5b3573..0000000 --- a/ratelimit_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package inspect - -import ( - "context" - "testing" - "time" -) - -func TestNewRateLimiter(t *testing.T) { - t.Run("creates limiter with default settings", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - if rl == nil { - t.Fatal("NewRateLimiter returned nil") - } - if rl.ActiveHosts() != 0 { - t.Errorf("expected 0 active hosts, got %d", rl.ActiveHosts()) - } - }) - - t.Run("creates limiter with cleanup interval option", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5, WithCleanupInterval(30*time.Second)) - defer rl.Close() - - if rl.cleanupInterval != 30*time.Second { - t.Errorf("expected cleanup interval 30s, got %v", rl.cleanupInterval) - } - }) - - t.Run("default cleanup interval is one minute", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - if rl.cleanupInterval != time.Minute { - t.Errorf("expected cleanup interval 1m, got %v", rl.cleanupInterval) - } - }) -} - -func TestRateLimiterAllow(t *testing.T) { - t.Run("allows first request", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - if !rl.Allow("example.com") { - t.Error("expected first request to be allowed") - } - }) - - t.Run("allows requests within burst", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - for i := 0; i < 5; i++ { - if !rl.Allow("example.com") { - t.Errorf("request %d should be allowed (within burst)", i+1) - } - } - }) - - t.Run("denies request beyond burst", func(t *testing.T) { - rl := NewRateLimiter(10.0, 2) - defer rl.Close() - - // Use up burst - rl.Allow("example.com") - rl.Allow("example.com") - - if rl.Allow("example.com") { - t.Error("expected request beyond burst to be denied") - } - }) - - t.Run("tracks active hosts", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - rl.Allow("host1.com") - rl.Allow("host2.com") - rl.Allow("host3.com") - - if rl.ActiveHosts() != 3 { - t.Errorf("expected 3 active hosts, got %d", rl.ActiveHosts()) - } - }) - - t.Run("each host has independent rate limit", func(t *testing.T) { - rl := NewRateLimiter(10.0, 2) - defer rl.Close() - - // Use up burst for host1 - rl.Allow("host1.com") - rl.Allow("host1.com") - - // host1 should be denied - if rl.Allow("host1.com") { - t.Error("expected host1 to be denied (burst exhausted)") - } - - // host2 should still be allowed (independent limit) - if !rl.Allow("host2.com") { - t.Error("expected host2 to be allowed (independent limit)") - } - }) -} - -func TestRateLimiterWait(t *testing.T) { - t.Run("wait allows first request immediately", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - ctx := context.Background() - start := time.Now() - err := rl.Wait(ctx, "example.com") - duration := time.Since(start) - - if err != nil { - t.Errorf("Wait returned error: %v", err) - } - // Should be nearly instant (< 10ms) - if duration > 10*time.Millisecond { - t.Errorf("Wait took too long: %v", duration) - } - }) - - t.Run("wait respects context cancellation", func(t *testing.T) { - rl := NewRateLimiter(10.0, 1) - defer rl.Close() - - // Use up the single token - rl.Wait(context.Background(), "example.com") - - // Create context with short timeout - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) - defer cancel() - - // This should block and then fail due to timeout - err := rl.Wait(ctx, "example.com") - if err == nil { - t.Error("expected Wait to fail with cancelled context") - } - }) - - t.Run("wait blocks when burst exhausted", func(t *testing.T) { - rl := NewRateLimiter(10.0, 1) - defer rl.Close() - - // Use up the single token - rl.Wait(context.Background(), "example.com") - - // This immediate call should fail (no tokens available) - // We need to use a timeout context to avoid infinite wait - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) - defer cancel() - - err := rl.Wait(ctx, "example.com") - if err == nil { - t.Error("expected Wait to fail when no tokens available") - } - }) -} - -func TestRateLimiterActiveHosts(t *testing.T) { - t.Run("starts with zero hosts", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - if rl.ActiveHosts() != 0 { - t.Errorf("expected 0 active hosts, got %d", rl.ActiveHosts()) - } - }) - - t.Run("counts unique hosts", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - rl.Allow("host1.com") - rl.Allow("host2.com") - rl.Allow("host1.com") // duplicate - - if rl.ActiveHosts() != 2 { - t.Errorf("expected 2 active hosts, got %d", rl.ActiveHosts()) - } - }) - - t.Run("allows empty host", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - rl.Allow("") - - if rl.ActiveHosts() != 1 { - t.Errorf("expected 1 active host, got %d", rl.ActiveHosts()) - } - }) -} - -func TestRateLimiterReapStale(t *testing.T) { - t.Run("removes hosts unused for 5 minutes", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5, WithCleanupInterval(10*time.Millisecond)) - - rl.Allow("host1.com") - rl.Allow("host2.com") - - if rl.ActiveHosts() != 2 { - t.Fatalf("expected 2 active hosts, got %d", rl.ActiveHosts()) - } - - // Manually set lastUsed to simulate stale hosts - rl.mu.Lock() - for _, hl := range rl.limits { - hl.lastUsed = time.Now().Add(-6 * time.Minute) - } - rl.mu.Unlock() - - // Trigger cleanup - rl.reapStale() - - if rl.ActiveHosts() != 0 { - t.Errorf("expected 0 active hosts after cleanup, got %d", rl.ActiveHosts()) - } - - rl.Close() - }) - - t.Run("keeps recently used hosts", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5) - defer rl.Close() - - rl.Allow("host1.com") - rl.Allow("host2.com") - - // Manually set one host to be stale - rl.mu.Lock() - rl.limits["host1.com"].lastUsed = time.Now().Add(-6 * time.Minute) - rl.mu.Unlock() - - rl.reapStale() - - if rl.ActiveHosts() != 1 { - t.Errorf("expected 1 active host after cleanup, got %d", rl.ActiveHosts()) - } - - // Should still have host2 - if !rl.Allow("host2.com") { - t.Error("expected host2 to still be available") - } - }) -} - -func TestRateLimiterClose(t *testing.T) { - t.Run("close stops cleanup goroutine", func(t *testing.T) { - rl := NewRateLimiter(10.0, 5, WithCleanupInterval(10*time.Millisecond)) - - rl.Close() - - // Allow should still work after close - if !rl.Allow("example.com") { - t.Error("expected Allow to work after Close") - } - }) -} From 778c3ff62ac22beba9106ab1d074f4daabd9d697 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 02:04:16 +0530 Subject: [PATCH 4/8] fix(contracts): record configured FailOn as explicitly set in ToContractReport A bare field copy left verify.Report.FailOnSet false, so a user-configured below-critical threshold did not take effect in the contract Failed() gate. Call SetFailOn like sight does. --- CHANGELOG.md | 5 +++++ contracts.go | 8 ++++++-- contracts_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde5dca..7def875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm dialer and URL-validation layers, and `ScanDir` registers its ephemeral listener address for the duration of that scan only. User-supplied URLs and all other private addresses remain blocked. +- **`ToContractReport` lost the configured fail threshold at the contract + layer.** The conversion field-copied `FailOn`, leaving the contract's + `FailOnSet` false, so a user-configured below-critical threshold did not + take effect in `verify.Report.Failed`. The converter now calls + `SetFailOn` so the threshold is recorded as explicitly configured. --- diff --git a/contracts.go b/contracts.go index f21597f..e21f270 100644 --- a/contracts.go +++ b/contracts.go @@ -42,12 +42,16 @@ func ToContractReport(r *Report) *verifycontracts.Report { if r == nil { return nil } - return &verifycontracts.Report{ + res := &verifycontracts.Report{ Target: r.Target, Findings: ToContractFindings(r.Findings), Stats: toContractStats(r.Stats), CrawledURLs: r.CrawledURLs, Duration: r.Duration, - FailOn: r.FailOn, } + // Set through the method so the threshold is recorded as explicitly + // configured; a bare field copy leaves FailOnSet false and the contract + // Failed() would fall back to its default threshold. + res.SetFailOn(r.FailOn) + return res } diff --git a/contracts_test.go b/contracts_test.go index bf7f881..894acf9 100644 --- a/contracts_test.go +++ b/contracts_test.go @@ -39,3 +39,30 @@ func TestToContractReport(t *testing.T) { t.Fatalf("unexpected findings conversion: %+v", got.Findings) } } + +func TestToContractReport_FailOnThresholdTakesEffect(t *testing.T) { + t.Parallel() + + base := func(sev Severity) *Report { + return &Report{ + Target: "https://example.com", + Findings: []Finding{ + {Check: "security", Severity: sev, URL: "https://example.com/", Message: "m"}, + }, + FailOn: SeverityMedium, + } + } + + low := ToContractReport(base(SeverityLow)) + if !low.FailOnSet { + t.Fatal("converted report must record the threshold as explicitly set") + } + if low.Failed() { + t.Fatal("low finding must not fail a Medium threshold") + } + + high := ToContractReport(base(SeverityHigh)) + if !high.Failed() { + t.Fatal("high finding must fail a Medium threshold") + } +} From f7424bb8f9fcef0af0dbbc0221352561e2638579 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 08:36:56 +0530 Subject: [PATCH 5/8] fix: depend on contracts branch APIs, bump Go to 1.26.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - go.mod: hawk-core-contracts v0.1.9 -> v0.1.13-0.20260815203243-0f60bf02 (branch fix/audit-sweep-2026-08 of hawk-core-contracts) — needed for verify.Report.SetFailOn/FailOnSet used by ToContractReport; re-pin to the tagged release once hawk-core-contracts#27 merges - go.mod + CI: Go 1.26.6 — 1.26.5 stdlib has reachable vulns that fail govulncheck --- .github/workflows/ci.yml | 2 +- go.mod | 4 ++-- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0935f8..b1e4b54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.26.5" + GO_VERSION: "1.26.6" GOPROXY: "https://proxy.golang.org,direct" GOPRIVATE: "github.com/GrayCodeAI/*" GONOSUMDB: "github.com/GrayCodeAI/*" diff --git a/go.mod b/go.mod index 29da55d..6020662 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module github.com/GrayCodeAI/inspect -go 1.26.5 +go 1.26.6 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.9 + github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 github.com/GrayCodeAI/hawk-mcpkit v0.1.4 github.com/mark3labs/mcp-go v0.49.0 golang.org/x/net v0.55.0 diff --git a/go.sum b/go.sum index 1a25514..9035197 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.9 h1:uXX/gtNM+3kxSEzu+rZkHykzcEaAbASn1lmPyOGMXvc= -github.com/GrayCodeAI/hawk-core-contracts v0.1.9/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 h1:HD8Y2SEx1mx6A1uLCLjQ9E1hokRP5T/Kw5XmIgmqcOQ= +github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/hawk-mcpkit v0.1.4 h1:tlhZXKDbI679I7c1feeY/pzErFwndD+R2CQf9sqHAVE= github.com/GrayCodeAI/hawk-mcpkit v0.1.4/go.mod h1:C32HPDRqiDETbVbMIbOTvguek6KImpLCffJjet7sqck= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= From d169014698b81c50666027c6b0095d90ad7b2bf7 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 08:52:00 +0530 Subject: [PATCH 6/8] fix(browser): bump contracts pseudo-version in nested module The browser submodule's go.mod still pinned hawk-core-contracts v0.1.9; its CI test step failed with 'updates to go.mod needed'. Aligns with the root module's v0.1.13-0.20260815203243-0f60bf02 pin. --- browser/go.mod | 7 +++---- browser/go.sum | 6 ++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/browser/go.mod b/browser/go.mod index 3e1bb80..c3a7e99 100644 --- a/browser/go.mod +++ b/browser/go.mod @@ -1,6 +1,6 @@ module github.com/GrayCodeAI/inspect/browser -go 1.26.5 +go 1.26.6 require ( github.com/GrayCodeAI/inspect v0.1.0 @@ -8,15 +8,14 @@ require ( ) require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.9 // indirect + github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 // indirect github.com/ysmood/fetchup v0.2.3 // indirect github.com/ysmood/goob v0.4.0 // indirect github.com/ysmood/got v0.40.0 // indirect github.com/ysmood/gson v0.7.3 // indirect github.com/ysmood/leakless v0.9.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/time v0.15.0 // indirect -gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/GrayCodeAI/inspect => ../ diff --git a/browser/go.sum b/browser/go.sum index b493d2f..3d5f753 100644 --- a/browser/go.sum +++ b/browser/go.sum @@ -1,5 +1,5 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.9 h1:uXX/gtNM+3kxSEzu+rZkHykzcEaAbASn1lmPyOGMXvc= -github.com/GrayCodeAI/hawk-core-contracts v0.1.9/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 h1:HD8Y2SEx1mx6A1uLCLjQ9E1hokRP5T/Kw5XmIgmqcOQ= +github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= @@ -18,8 +18,6 @@ github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From deb67fe0cda6c01f4d753425f06be29fd3a469cf Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 09:12:14 +0530 Subject: [PATCH 7/8] chore: re-pin hawk-core-contracts to merged main contracts#27 squash-merged as 16ebcfd; move root and browser modules from the branch pseudo-version to the merged main pseudo-version. --- browser/go.mod | 2 +- browser/go.sum | 2 ++ go.mod | 2 +- go.sum | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/browser/go.mod b/browser/go.mod index c3a7e99..89a3779 100644 --- a/browser/go.mod +++ b/browser/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 // indirect + github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e // indirect github.com/ysmood/fetchup v0.2.3 // indirect github.com/ysmood/goob v0.4.0 // indirect github.com/ysmood/got v0.40.0 // indirect diff --git a/browser/go.sum b/browser/go.sum index 3d5f753..c790830 100644 --- a/browser/go.sum +++ b/browser/go.sum @@ -1,5 +1,7 @@ github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 h1:HD8Y2SEx1mx6A1uLCLjQ9E1hokRP5T/Kw5XmIgmqcOQ= github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e h1:cG9bLB3rWmMVU/7GwDxUIrEFbGHz7OTD5s9I4K+udr0= +github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= diff --git a/go.mod b/go.mod index 6020662..8ab1a61 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/inspect go 1.26.6 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 + github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e github.com/GrayCodeAI/hawk-mcpkit v0.1.4 github.com/mark3labs/mcp-go v0.49.0 golang.org/x/net v0.55.0 diff --git a/go.sum b/go.sum index 9035197..8f1a7a1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 h1:HD8Y2SEx1mx6A1uLCLjQ9E1hokRP5T/Kw5XmIgmqcOQ= github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e h1:cG9bLB3rWmMVU/7GwDxUIrEFbGHz7OTD5s9I4K+udr0= +github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/hawk-mcpkit v0.1.4 h1:tlhZXKDbI679I7c1feeY/pzErFwndD+R2CQf9sqHAVE= github.com/GrayCodeAI/hawk-mcpkit v0.1.4/go.mod h1:C32HPDRqiDETbVbMIbOTvguek6KImpLCffJjet7sqck= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= From b5b658421c8b798d03448a4309e516e5e1aeba18 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 09:19:02 +0530 Subject: [PATCH 8/8] chore: go mod tidy after contracts re-pin --- browser/go.sum | 2 -- go.sum | 2 -- 2 files changed, 4 deletions(-) diff --git a/browser/go.sum b/browser/go.sum index c790830..cfe4807 100644 --- a/browser/go.sum +++ b/browser/go.sum @@ -1,5 +1,3 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 h1:HD8Y2SEx1mx6A1uLCLjQ9E1hokRP5T/Kw5XmIgmqcOQ= -github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e h1:cG9bLB3rWmMVU/7GwDxUIrEFbGHz7OTD5s9I4K+udr0= github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= diff --git a/go.sum b/go.sum index 8f1a7a1..c216382 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0 h1:HD8Y2SEx1mx6A1uLCLjQ9E1hokRP5T/Kw5XmIgmqcOQ= -github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260815203243-0f60bf0259c0/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e h1:cG9bLB3rWmMVU/7GwDxUIrEFbGHz7OTD5s9I4K+udr0= github.com/GrayCodeAI/hawk-core-contracts v0.1.13-0.20260816034142-16ebcfd5ad6e/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/hawk-mcpkit v0.1.4 h1:tlhZXKDbI679I7c1feeY/pzErFwndD+R2CQf9sqHAVE=