From 67d4ce4ff4a1ce26cdf1c4c399283a14159cfcd8 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:32:32 +0000 Subject: [PATCH 1/5] Agent proposal for #7 --- internal/fetch/passive.go | 395 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 internal/fetch/passive.go diff --git a/internal/fetch/passive.go b/internal/fetch/passive.go new file mode 100644 index 0000000..5cbafea --- /dev/null +++ b/internal/fetch/passive.go @@ -0,0 +1,395 @@ +package fetch + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "slices" + "strconv" + "sync" + "syscall" + "time" + + touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" +) + +// Limits from docs/architecture/touchstone.md, "The passive rule is +// enforced in one place". +const ( + DefaultBudget = 50 + MaxBudget = 200 + MaxResponseBytes = 2 << 20 // 2 MiB per response + MaxScanBytes = 20 << 20 // 20 MiB per scan + RequestTimeout = 10 * time.Second + + maxRedirects = 10 +) + +// DiscoveryPaths are the only paths a DISCOVERY_PATH request may fetch, +// and only on the target's origin. +var DiscoveryPaths = []string{ + "/.well-known/security.txt", + "/security.txt", + "/.well-known/csaf/provider-metadata.json", + "/.well-known/tea", +} + +// ErrRefused is wrapped by every error returned for a request the +// PassiveFetcher would not send (or, for redirects and private +// destinations, would not finish sending). +var ErrRefused = errors.New("fetch: refused") + +// Resolver resolves a host name to IP addresses. *net.Resolver satisfies +// it; tests inject a fake so "a name resolving to a private IP" needs no +// real DNS. +type Resolver interface { + LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) +} + +// Options configures a PassiveFetcher. +type Options struct { + // Budget is the maximum number of requests (redirect hops included) + // sent to the vendor. 0 means DefaultBudget; more than MaxBudget is an + // error. + Budget uint32 + // AllowPrivate disables the SSRF guard. Only the CLI may set it (for + // scanning a local fixture); the server must never honor it. + AllowPrivate bool + // Version is the tool version reported in the User-Agent. + Version string + // Resolver is used for host names; net.DefaultResolver if nil. + Resolver Resolver +} + +// PassiveFetcher is the only Fetcher that talks to a vendor site. It sends +// GET requests only, for one of the allowed reasons, within the request +// budget and size caps, never to a private address, and records every +// request (refused ones included) for the report's checked list. +type PassiveFetcher struct { + target *url.URL + userAgent string + client *http.Client + + // Overridable by tests. blocked is the SSRF guard's predicate (nil + // with AllowPrivate); timeout is RequestTimeout. + blocked func(netip.Addr) bool + timeout time.Duration + + mu sync.Mutex + budget uint32 + used uint32 + scanBytes int64 + checked []*touchstonev1.CheckedLocation +} + +var _ Fetcher = (*PassiveFetcher)(nil) + +// New returns a PassiveFetcher for one scan of target, the URL the user +// entered. +func New(target string, opts Options) (*PassiveFetcher, error) { + t, err := parseHTTPURL(target) + if err != nil { + return nil, err + } + budget := opts.Budget + if budget == 0 { + budget = DefaultBudget + } + if budget > MaxBudget { + return nil, fmt.Errorf("fetch: request budget %d exceeds the maximum of %d", budget, MaxBudget) + } + version := opts.Version + if version == "" { + version = "dev" + } + resolver := opts.Resolver + if resolver == nil { + resolver = net.DefaultResolver + } + + f := &PassiveFetcher{ + target: t, + userAgent: "Touchstone/" + version + " (+https://github.com/FootprintAI/touchstone)", + timeout: RequestTimeout, + budget: budget, + } + if !opts.AllowPrivate { + f.blocked = IsPrivate + } + dialer := &net.Dialer{Timeout: RequestTimeout, Control: f.guardControl} + f.client = &http.Client{ + Transport: &http.Transport{ + // No proxy: the guard must see the vendor's address, not a proxy's. + Proxy: nil, + DialContext: resolvingDialer(dialer, resolver), + TLSHandshakeTimeout: RequestTimeout, + ResponseHeaderTimeout: RequestTimeout, + MaxIdleConnsPerHost: 2, + }, + CheckRedirect: f.checkRedirect, + } + return f, nil +} + +// Get fetches rawURL for reason. Refused requests, transport errors and +// cap violations return an error; any HTTP status is returned as a +// Response. Every call is recorded in Checked. +func (f *PassiveFetcher) Get(ctx context.Context, rawURL string, reason Reason) (*Response, error) { + u, err := f.allow(rawURL, reason) + if err != nil { + f.record(rawURL, reason, touchstonev1.CheckedLocation_RESULT_REFUSED, err) + return nil, err + } + if err := f.reserve(); err != nil { + f.record(rawURL, reason, touchstonev1.CheckedLocation_RESULT_REFUSED, err) + return nil, err + } + + ctx, cancel := context.WithTimeout(ctx, f.timeout) + defer cancel() + resp, err := f.do(ctx, u) + if err != nil { + result := touchstonev1.CheckedLocation_RESULT_ERROR + if errors.Is(err, ErrRefused) { + result = touchstonev1.CheckedLocation_RESULT_REFUSED + } + f.record(rawURL, reason, result, err) + return nil, err + } + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + f.record(rawURL, reason, touchstonev1.CheckedLocation_RESULT_FOUND, nil) + case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone: + f.record(rawURL, reason, touchstonev1.CheckedLocation_RESULT_NOT_FOUND, nil) + default: + f.record(rawURL, reason, touchstonev1.CheckedLocation_RESULT_ERROR, fmt.Errorf("HTTP %d", resp.StatusCode)) + } + return resp, nil +} + +func (f *PassiveFetcher) do(ctx context.Context, u *url.URL) (*Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", f.userAgent) + resp, err := f.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + limit := f.remainingBytes() + body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + f.addScanBytes(int64(len(body))) + if err != nil { + return nil, err + } + if int64(len(body)) > limit { + if limit < MaxResponseBytes { + return nil, fmt.Errorf("fetch: %s: scan exceeds the %d-byte total cap", u, MaxScanBytes) + } + return nil, fmt.Errorf("fetch: %s: response exceeds the %d-byte cap", u, MaxResponseBytes) + } + return &Response{ + URL: resp.Request.URL.String(), + StatusCode: resp.StatusCode, + Header: resp.Header, + Body: body, + }, nil +} + +// allow applies the reason allowlist and returns the parsed URL. +func (f *PassiveFetcher) allow(rawURL string, reason Reason) (*url.URL, error) { + u, err := parseHTTPURL(rawURL) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrRefused, err) + } + switch reason { + case ReasonTarget: + if u.String() != f.target.String() { + return nil, fmt.Errorf("%w: TARGET must be the scanned URL %s, got %s", ErrRefused, f.target, u) + } + case ReasonDiscoveryPath: + if !sameOrigin(u, f.target) || u.RawQuery != "" || !slices.Contains(DiscoveryPaths, u.EscapedPath()) { + return nil, fmt.Errorf("%w: %s is not a discovery path on %s", ErrRefused, u, origin(f.target)) + } + case ReasonSubresource, ReasonDeclared: + // Taken from a fetched document by the caller; nothing to check + // beyond the URL itself. + default: + return nil, fmt.Errorf("%w: reason %q is not allowed", ErrRefused, reason) + } + return u, nil +} + +// reserve spends one request from the budget. +func (f *PassiveFetcher) reserve() error { + f.mu.Lock() + defer f.mu.Unlock() + if f.used >= f.budget { + return fmt.Errorf("%w: request budget of %d exhausted", ErrRefused, f.budget) + } + if f.scanBytes >= MaxScanBytes { + return fmt.Errorf("%w: scan reached the %d-byte total cap", ErrRefused, MaxScanBytes) + } + f.used++ + return nil +} + +func (f *PassiveFetcher) checkRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("%w: more than %d redirects", ErrRefused, maxRedirects) + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return fmt.Errorf("%w: redirect to non-http(s) URL %s", ErrRefused, req.URL) + } + // The redirect target's address is checked by the dial guard. + return f.reserve() +} + +func (f *PassiveFetcher) remainingBytes() int64 { + f.mu.Lock() + defer f.mu.Unlock() + return min(MaxResponseBytes, MaxScanBytes-f.scanBytes) +} + +func (f *PassiveFetcher) addScanBytes(n int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.scanBytes += n +} + +func (f *PassiveFetcher) record(rawURL string, reason Reason, result touchstonev1.CheckedLocation_Result, err error) { + loc := &touchstonev1.CheckedLocation{Url: rawURL, Reason: string(reason), Result: result} + if err != nil { + loc.Error = err.Error() + } + f.mu.Lock() + defer f.mu.Unlock() + f.checked = append(f.checked, loc) +} + +// Checked returns the request log in call order, for ScanReport.checked. +func (f *PassiveFetcher) Checked() []*touchstonev1.CheckedLocation { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.checked) +} + +// RequestsUsed returns how many requests were sent, redirect hops +// included, for ScanReport.requests_used. +func (f *PassiveFetcher) RequestsUsed() uint32 { + f.mu.Lock() + defer f.mu.Unlock() + return f.used +} + +// RequestBudget returns the effective budget, for ScanReport.request_budget. +func (f *PassiveFetcher) RequestBudget() uint32 { + return f.budget +} + +func parseHTTPURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("fetch: invalid URL %q: %v", raw, err) + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("fetch: %q is not an absolute http(s) URL", raw) + } + if u.User != nil { + return nil, fmt.Errorf("fetch: %q carries credentials", raw) + } + return u, nil +} + +func origin(u *url.URL) string { + port := u.Port() + if port == "" { + port = map[string]string{"http": "80", "https": "443"}[u.Scheme] + } + return u.Scheme + "://" + net.JoinHostPort(u.Hostname(), port) +} + +func sameOrigin(a, b *url.URL) bool { + return origin(a) == origin(b) +} + +// resolvingDialer resolves host names with r and dials each address with +// d, so d.Control sees every IP actually connected to. +func resolvingDialer(d *net.Dialer, r Resolver) func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + if _, err := netip.ParseAddr(host); err == nil { + return d.DialContext(ctx, network, addr) + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return nil, err + } + ips, err := r.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, fmt.Errorf("fetch: no addresses for %s", host) + } + var errs []error + for _, ip := range ips { + conn, err := d.DialContext(ctx, network, netip.AddrPortFrom(ip.Unmap(), uint16(port)).String()) + if err == nil { + return conn, nil + } + errs = append(errs, err) + } + return nil, errors.Join(errs...) + } +} + +// guardControl is the net.Dialer.Control hook of the SSRF guard: it runs +// after resolution, right before connect, so it covers host names, +// DNS rebinding and redirects alike. +func (f *PassiveFetcher) guardControl(_, address string, _ syscall.RawConn) error { + if f.blocked == nil { + return nil + } + ap, err := netip.ParseAddrPort(address) + if err != nil { + return fmt.Errorf("%w: unparseable dial address %q", ErrRefused, address) + } + if ip := ap.Addr(); f.blocked(ip) { + return fmt.Errorf("%w: %s is a private or reserved address", ErrRefused, ip) + } + return nil +} + +var blockedPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), // "this network" + netip.MustParsePrefix("100.64.0.0/10"), // CGNAT, RFC 6598 +} + +// IsPrivate reports whether the SSRF guard refuses ip: loopback, RFC 1918, +// link-local (incl. 169.254.169.254), CGNAT, IPv6 ULA (fc00::/7), +// unspecified and multicast addresses. +func IsPrivate(ip netip.Addr) bool { + ip = ip.Unmap() + if !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { + return true + } + for _, p := range blockedPrefixes { + if p.Contains(ip) { + return true + } + } + return false +} From 57470bb56d6fe6d54d91733e8f701442622de4a9 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:32:34 +0000 Subject: [PATCH 2/5] Agent proposal for #7 --- internal/fetch/passive_test.go | 481 +++++++++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 internal/fetch/passive_test.go diff --git a/internal/fetch/passive_test.go b/internal/fetch/passive_test.go new file mode 100644 index 0000000..ef98d85 --- /dev/null +++ b/internal/fetch/passive_test.go @@ -0,0 +1,481 @@ +package fetch + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/netip" + "strconv" + "strings" + "sync" + "testing" + "time" + + touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" +) + +// seen records what an httptest server received. +type seen struct { + mu sync.Mutex + reqs []*http.Request +} + +func (s *seen) add(r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + s.reqs = append(s.reqs, r) +} + +func (s *seen) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.reqs) +} + +// okServer answers every request with 200 "ok" and records it. +func okServer(t *testing.T) (*httptest.Server, *seen) { + t.Helper() + s := &seen{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.add(r) + fmt.Fprint(w, "ok") + })) + t.Cleanup(srv.Close) + return srv, s +} + +func mustNew(t *testing.T, target string, opts Options) *PassiveFetcher { + t.Helper() + f, err := New(target, opts) + if err != nil { + t.Fatalf("New(%q): %v", target, err) + } + return f +} + +func lastChecked(t *testing.T, f *PassiveFetcher) *touchstonev1.CheckedLocation { + t.Helper() + c := f.Checked() + if len(c) == 0 { + t.Fatal("Checked: empty, want the request recorded") + } + return c[len(c)-1] +} + +func TestAllowReason(t *testing.T) { + srv, s := okServer(t) + target := srv.URL + "/" + tests := []struct { + name string + url string + reason Reason + refused bool + }{ + {"target", target, ReasonTarget, false}, + {"target other path", srv.URL + "/admin", ReasonTarget, true}, + {"subresource", srv.URL + "/static/app.js", ReasonSubresource, false}, + {"declared", srv.URL + "/csaf/advisory.json", ReasonDeclared, false}, + {"discovery security.txt", srv.URL + "/.well-known/security.txt", ReasonDiscoveryPath, false}, + {"discovery root security.txt", srv.URL + "/security.txt", ReasonDiscoveryPath, false}, + {"discovery csaf", srv.URL + "/.well-known/csaf/provider-metadata.json", ReasonDiscoveryPath, false}, + {"discovery tea", srv.URL + "/.well-known/tea", ReasonDiscoveryPath, false}, + {"discovery unknown path", srv.URL + "/.env", ReasonDiscoveryPath, true}, + {"discovery with query", srv.URL + "/.well-known/security.txt?x=1", ReasonDiscoveryPath, true}, + {"discovery other origin", "https://other.example/.well-known/security.txt", ReasonDiscoveryPath, true}, + {"forge api reason", srv.URL + "/repos/x", Reason("FORGE_API"), true}, + {"empty reason", srv.URL + "/x", Reason(""), true}, + {"unknown reason", srv.URL + "/x", Reason("CRAWL"), true}, + {"non-http scheme", "ftp://example.com/x", ReasonSubresource, true}, + {"credentials in URL", "http://user:pw@" + strings.TrimPrefix(srv.URL, "http://") + "/x", ReasonSubresource, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := mustNew(t, target, Options{AllowPrivate: true}) + before := s.count() + _, err := f.Get(context.Background(), tt.url, tt.reason) + got := lastChecked(t, f) + if got.GetUrl() != tt.url || got.GetReason() != string(tt.reason) { + t.Errorf("Checked = %v, want url %q reason %q", got, tt.url, tt.reason) + } + if tt.refused { + if !errors.Is(err, ErrRefused) { + t.Fatalf("Get: err = %v, want ErrRefused", err) + } + if got.GetResult() != touchstonev1.CheckedLocation_RESULT_REFUSED { + t.Errorf("result = %v, want RESULT_REFUSED", got.GetResult()) + } + if s.count() != before { + t.Error("refused request reached the server") + } + if f.RequestsUsed() != 0 { + t.Errorf("RequestsUsed = %d, want 0 for a refused request", f.RequestsUsed()) + } + return + } + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.GetResult() != touchstonev1.CheckedLocation_RESULT_FOUND { + t.Errorf("result = %v, want RESULT_FOUND", got.GetResult()) + } + }) + } +} + +func TestRequestIsPassiveGET(t *testing.T) { + srv, s := okServer(t) + f := mustNew(t, srv.URL+"/page", Options{AllowPrivate: true, Version: "1.2.3"}) + if _, err := f.Get(context.Background(), srv.URL+"/page", ReasonTarget); err != nil { + t.Fatalf("Get: %v", err) + } + if _, err := f.Get(context.Background(), srv.URL+"/app.js", ReasonSubresource); err != nil { + t.Fatalf("Get: %v", err) + } + if s.count() != 2 { + t.Fatalf("server saw %d requests, want 2", s.count()) + } + for _, r := range s.reqs { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if ua := r.Header.Get("User-Agent"); ua != "Touchstone/1.2.3 (+https://github.com/FootprintAI/touchstone)" { + t.Errorf("User-Agent = %q", ua) + } + if r.URL.RawQuery != "" { + t.Errorf("query %q added to %s", r.URL.RawQuery, r.URL.Path) + } + } +} + +func TestResultFromStatus(t *testing.T) { + tests := []struct { + status int + want touchstonev1.CheckedLocation_Result + }{ + {200, touchstonev1.CheckedLocation_RESULT_FOUND}, + {404, touchstonev1.CheckedLocation_RESULT_NOT_FOUND}, + {410, touchstonev1.CheckedLocation_RESULT_NOT_FOUND}, + {500, touchstonev1.CheckedLocation_RESULT_ERROR}, + } + for _, tt := range tests { + t.Run(strconv.Itoa(tt.status), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + })) + defer srv.Close() + f := mustNew(t, srv.URL, Options{AllowPrivate: true}) + resp, err := f.Get(context.Background(), srv.URL, ReasonTarget) + if err != nil { + t.Fatalf("Get: %v", err) + } + if resp.StatusCode != tt.status { + t.Errorf("StatusCode = %d, want %d", resp.StatusCode, tt.status) + } + if got := lastChecked(t, f).GetResult(); got != tt.want { + t.Errorf("result = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBudget(t *testing.T) { + tests := []struct { + name string + budget uint32 + wantBudget uint32 + newErr bool + }{ + {"default", 0, DefaultBudget, false}, + {"one", 1, 1, false}, + {"max", MaxBudget, MaxBudget, false}, + {"over max", MaxBudget + 1, 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv, s := okServer(t) + f, err := New(srv.URL, Options{AllowPrivate: true, Budget: tt.budget}) + if tt.newErr { + if err == nil { + t.Fatal("New: want error for a budget over the maximum") + } + return + } + if err != nil { + t.Fatalf("New: %v", err) + } + if f.RequestBudget() != tt.wantBudget { + t.Fatalf("RequestBudget = %d, want %d", f.RequestBudget(), tt.wantBudget) + } + for i := range tt.wantBudget { + if _, err := f.Get(context.Background(), srv.URL+"/r"+strconv.Itoa(int(i)), ReasonSubresource); err != nil { + t.Fatalf("request %d: %v", i+1, err) + } + } + _, err = f.Get(context.Background(), srv.URL+"/over", ReasonSubresource) + if !errors.Is(err, ErrRefused) { + t.Fatalf("request %d: err = %v, want ErrRefused", tt.wantBudget+1, err) + } + if got := lastChecked(t, f).GetResult(); got != touchstonev1.CheckedLocation_RESULT_REFUSED { + t.Errorf("result = %v, want RESULT_REFUSED", got) + } + if s.count() != int(tt.wantBudget) || f.RequestsUsed() != tt.wantBudget { + t.Errorf("server saw %d, RequestsUsed %d; want %d", s.count(), f.RequestsUsed(), tt.wantBudget) + } + }) + } +} + +func TestBudgetCountsRedirects(t *testing.T) { + s := &seen{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.add(r) + if r.URL.Path == "/a" { + http.Redirect(w, r, "/b", http.StatusFound) + return + } + fmt.Fprint(w, "ok") + })) + defer srv.Close() + + f := mustNew(t, srv.URL+"/a", Options{AllowPrivate: true, Budget: 1}) + if _, err := f.Get(context.Background(), srv.URL+"/a", ReasonTarget); !errors.Is(err, ErrRefused) { + t.Fatalf("Get: err = %v, want ErrRefused for the redirect hop over budget", err) + } + if s.count() != 1 { + t.Errorf("server saw %d requests, want 1", s.count()) + } + + f = mustNew(t, srv.URL+"/a", Options{AllowPrivate: true, Budget: 2}) + resp, err := f.Get(context.Background(), srv.URL+"/a", ReasonTarget) + if err != nil { + t.Fatalf("Get: %v", err) + } + if resp.URL != srv.URL+"/b" || f.RequestsUsed() != 2 { + t.Errorf("URL = %s, RequestsUsed = %d; want %s/b, 2", resp.URL, f.RequestsUsed(), srv.URL) + } +} + +func TestSizeCap(t *testing.T) { + const mib = 1 << 20 + tests := []struct { + name string + sizes []int // body size of each successive request in one scan + want []touchstonev1.CheckedLocation_Result + }{ + {"response at cap", []int{2 * mib}, []touchstonev1.CheckedLocation_Result{touchstonev1.CheckedLocation_RESULT_FOUND}}, + {"response over cap", []int{2*mib + 1}, []touchstonev1.CheckedLocation_Result{touchstonev1.CheckedLocation_RESULT_ERROR}}, + { + "scan at cap then refused", + []int{2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 1}, + []touchstonev1.CheckedLocation_Result{ + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_REFUSED, + }, + }, + { + "response crossing scan cap", + []int{2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, 2 * mib, mib, 2 * mib}, + []touchstonev1.CheckedLocation_Result{ + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_FOUND, touchstonev1.CheckedLocation_RESULT_FOUND, + touchstonev1.CheckedLocation_RESULT_ERROR, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n, _ := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/")) + w.Write(bytes.Repeat([]byte("a"), n)) + })) + defer srv.Close() + f := mustNew(t, srv.URL, Options{AllowPrivate: true}) + for i, n := range tt.sizes { + resp, err := f.Get(context.Background(), srv.URL+"/"+strconv.Itoa(n), ReasonSubresource) + got := lastChecked(t, f).GetResult() + if got != tt.want[i] { + t.Fatalf("request %d (%d bytes): result = %v, want %v (err %v)", i+1, n, got, tt.want[i], err) + } + if got == touchstonev1.CheckedLocation_RESULT_FOUND && len(resp.Body) != n { + t.Fatalf("request %d: body is %d bytes, want %d", i+1, len(resp.Body), n) + } + if got != touchstonev1.CheckedLocation_RESULT_FOUND && err == nil { + t.Fatalf("request %d: want error", i+1) + } + } + }) + } +} + +func TestTimeout(t *testing.T) { + if RequestTimeout != 10*time.Second { + t.Fatalf("RequestTimeout = %v, want 10s", RequestTimeout) + } + tests := []struct { + name string + delay time.Duration + want touchstonev1.CheckedLocation_Result + }{ + {"within timeout", 0, touchstonev1.CheckedLocation_RESULT_FOUND}, + {"slow headers", time.Second, touchstonev1.CheckedLocation_RESULT_ERROR}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-time.After(tt.delay): + case <-r.Context().Done(): + } + fmt.Fprint(w, "ok") + })) + defer srv.Close() + f := mustNew(t, srv.URL, Options{AllowPrivate: true}) + f.timeout = 100 * time.Millisecond + _, err := f.Get(context.Background(), srv.URL, ReasonTarget) + if got := lastChecked(t, f).GetResult(); got != tt.want { + t.Fatalf("result = %v, want %v (err %v)", got, tt.want, err) + } + if tt.want == touchstonev1.CheckedLocation_RESULT_ERROR && !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("err = %v, want a deadline error", err) + } + }) + } +} + +// fakeResolver maps host names to addresses without touching DNS. +type fakeResolver map[string][]netip.Addr + +func (r fakeResolver) LookupNetIP(_ context.Context, _, host string) ([]netip.Addr, error) { + if ips, ok := r[host]; ok { + return ips, nil + } + return nil, fmt.Errorf("fakeResolver: no such host %q", host) +} + +func TestSSRFGuard(t *testing.T) { + srv, s := okServer(t) + port := strings.TrimPrefix(srv.URL, "http://127.0.0.1:") + resolver := fakeResolver{ + "intranet.test": {netip.MustParseAddr("10.1.2.3")}, + "loopback.test": {netip.MustParseAddr("127.0.0.1")}, + } + tests := []struct { + name string + url string + }{ + {"loopback", srv.URL + "/"}, + {"loopback other", "http://127.8.9.10:" + port + "/"}, + {"rfc1918 10/8", "http://10.0.0.1/"}, + {"rfc1918 172.16/12", "http://172.16.5.4/"}, + {"rfc1918 192.168/16", "http://192.168.1.1/"}, + {"link-local metadata", "http://169.254.169.254/latest/meta-data/"}, + {"cgnat", "http://100.64.0.1/"}, + {"unspecified", "http://0.0.0.0:" + port + "/"}, + {"ipv6 loopback", "http://[::1]:" + port + "/"}, + {"ipv4-mapped loopback", "http://[::ffff:127.0.0.1]:" + port + "/"}, + {"ipv6 ula fc00", "http://[fc00::1]/"}, + {"ipv6 ula fd00", "http://[fd12:3456::1]/"}, + {"hostname resolving to private", "http://intranet.test/"}, + {"hostname resolving to loopback", "http://loopback.test:" + port + "/"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := mustNew(t, tt.url, Options{Resolver: resolver}) + before := s.count() + _, err := f.Get(context.Background(), tt.url, ReasonTarget) + if !errors.Is(err, ErrRefused) { + t.Fatalf("Get(%s): err = %v, want ErrRefused", tt.url, err) + } + if got := lastChecked(t, f).GetResult(); got != touchstonev1.CheckedLocation_RESULT_REFUSED { + t.Errorf("result = %v, want RESULT_REFUSED", got) + } + if s.count() != before { + t.Error("request reached the loopback server") + } + }) + } +} + +func TestSSRFGuardRefusesRedirectToPrivate(t *testing.T) { + for _, dest := range []string{"http://169.254.169.254/latest/meta-data/", "http://10.0.0.1/", "http://intranet.test/"} { + t.Run(dest, func(t *testing.T) { + srv := httptest.NewServer(http.RedirectHandler(dest, http.StatusFound)) + defer srv.Close() + f := mustNew(t, srv.URL, Options{Resolver: fakeResolver{"intranet.test": {netip.MustParseAddr("192.168.0.10")}}}) + // Let only the test server's own address through, so the + // redirect is what the guard has to catch. + serverAddr := netip.MustParseAddrPort(strings.TrimPrefix(srv.URL, "http://")).Addr() + f.blocked = func(ip netip.Addr) bool { return ip != serverAddr && IsPrivate(ip) } + + _, err := f.Get(context.Background(), srv.URL, ReasonTarget) + if !errors.Is(err, ErrRefused) { + t.Fatalf("Get: err = %v, want ErrRefused", err) + } + if got := lastChecked(t, f).GetResult(); got != touchstonev1.CheckedLocation_RESULT_REFUSED { + t.Errorf("result = %v, want RESULT_REFUSED", got) + } + }) + } +} + +func TestAllowPrivate(t *testing.T) { + srv, s := okServer(t) + f := mustNew(t, srv.URL, Options{AllowPrivate: true}) + if _, err := f.Get(context.Background(), srv.URL, ReasonTarget); err != nil { + t.Fatalf("Get with AllowPrivate: %v", err) + } + if s.count() != 1 { + t.Errorf("server saw %d requests, want 1", s.count()) + } +} + +func TestIsPrivate(t *testing.T) { + tests := []struct { + ip string + want bool + }{ + {"127.0.0.1", true}, + {"10.255.255.255", true}, + {"172.31.0.1", true}, + {"172.32.0.1", false}, + {"192.168.0.1", true}, + {"169.254.169.254", true}, + {"100.64.0.1", true}, + {"100.127.255.255", true}, + {"100.128.0.1", false}, + {"0.0.0.0", true}, + {"224.0.0.1", true}, + {"::1", true}, + {"::", true}, + {"fc00::1", true}, + {"fdff::1", true}, + {"fe80::1", true}, + {"::ffff:10.0.0.1", true}, + {"8.8.8.8", false}, + {"93.184.216.34", false}, + {"2606:4700:4700::1111", false}, + } + for _, tt := range tests { + if got := IsPrivate(netip.MustParseAddr(tt.ip)); got != tt.want { + t.Errorf("IsPrivate(%s) = %v, want %v", tt.ip, got, tt.want) + } + } +} + +func TestNewRejectsBadTarget(t *testing.T) { + for _, target := range []string{"", "example.com", "ftp://example.com/", "http://"} { + if _, err := New(target, Options{}); err == nil { + t.Errorf("New(%q): want error", target) + } + } +} From 531a092aa67fa6dd4983a2223687877165537793 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:32:35 +0000 Subject: [PATCH 3/5] Agent proposal for #7 --- internal/fetch/fetch.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 4413609..f3c56a4 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -1,8 +1,6 @@ // Package fetch defines the Fetcher seam: the one way any other package -// is allowed to read bytes from a vendor site. The real implementation -// (PassiveFetcher, see docs/architecture/touchstone.md) lands in slice -// S1; this package only fixes the interface so S0 callers and their -// tests can be written against it now. +// is allowed to read bytes from a vendor site. PassiveFetcher is the real +// implementation (see docs/architecture/touchstone.md); Fake is for tests. package fetch import "context" From f1baf524b19d8d8ea9aedc7a3817bf7aed04e759 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:32:37 +0000 Subject: [PATCH 4/5] Agent proposal for #7 --- cmd/touchstone/main.go | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/cmd/touchstone/main.go b/cmd/touchstone/main.go index 97c3cd0..932be1b 100644 --- a/cmd/touchstone/main.go +++ b/cmd/touchstone/main.go @@ -6,29 +6,44 @@ package main import ( "context" "errors" + "flag" "fmt" "io" "os" touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "github.com/FootprintAI/touchstone/internal/fetch" "github.com/FootprintAI/touchstone/internal/service" "google.golang.org/protobuf/encoding/protojson" ) -const usage = "usage: touchstone scan " +const usage = "usage: touchstone scan [--allow-private] " + +// newService builds the ScanService for one CLI invocation. The fetch +// options carry CLI-only settings such as --allow-private, which the +// server never accepts. +type newService func(fetch.Options) touchstonev1.ScanServiceServer func main() { - if err := run(context.Background(), os.Args[1:], os.Stdout, &service.Stub{}); err != nil { + newSvc := func(fetch.Options) touchstonev1.ScanServiceServer { return &service.Stub{} } + if err := run(context.Background(), os.Args[1:], os.Stdout, newSvc); err != nil { fmt.Fprintln(os.Stderr, "touchstone:", err) os.Exit(2) } } -func run(ctx context.Context, args []string, out io.Writer, svc touchstonev1.ScanServiceServer) error { - if len(args) != 2 || args[0] != "scan" { +func run(ctx context.Context, args []string, out io.Writer, newSvc newService) error { + if len(args) == 0 || args[0] != "scan" { + return errors.New(usage) + } + fs := flag.NewFlagSet("scan", flag.ContinueOnError) + fs.SetOutput(io.Discard) + allowPrivate := fs.Bool("allow-private", false, "allow fetching loopback and private addresses (local fixtures only)") + if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 1 { return errors.New(usage) } - report, err := svc.Scan(ctx, &touchstonev1.ScanRequest{Url: args[1]}) + svc := newSvc(fetch.Options{AllowPrivate: *allowPrivate, Version: service.ToolVersion}) + report, err := svc.Scan(ctx, &touchstonev1.ScanRequest{Url: fs.Arg(0)}) if err != nil { return err } From e2737e55b755705193bf3acef11a9cfdfd310d51 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:32:38 +0000 Subject: [PATCH 5/5] Agent proposal for #7 --- cmd/touchstone/main_test.go | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/cmd/touchstone/main_test.go b/cmd/touchstone/main_test.go index dfe88e3..9e5f3d7 100644 --- a/cmd/touchstone/main_test.go +++ b/cmd/touchstone/main_test.go @@ -11,6 +11,7 @@ import ( "time" touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "github.com/FootprintAI/touchstone/internal/fetch" "github.com/FootprintAI/touchstone/internal/service" "github.com/santhosh-tekuri/jsonschema/v6" "google.golang.org/protobuf/encoding/protojson" @@ -24,13 +25,35 @@ var ( goldenPath = filepath.Join("..", "..", "testdata", "report.golden.json") ) -func fixedStub() *service.Stub { +func fixedStub(fetch.Options) touchstonev1.ScanServiceServer { return &service.Stub{Now: func() time.Time { return time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC) }} } +func TestScanAllowPrivateFlag(t *testing.T) { + for _, tt := range []struct { + args []string + want bool + }{ + {[]string{"scan", "http://127.0.0.1:8080"}, false}, + {[]string{"scan", "--allow-private", "http://127.0.0.1:8080"}, true}, + } { + var got *fetch.Options + newSvc := func(o fetch.Options) touchstonev1.ScanServiceServer { + got = &o + return fixedStub(o) + } + if err := run(context.Background(), tt.args, &bytes.Buffer{}, newSvc); err != nil { + t.Fatalf("run(%q): %v", tt.args, err) + } + if got == nil || got.AllowPrivate != tt.want { + t.Errorf("run(%q): fetch options %+v, want AllowPrivate=%v", tt.args, got, tt.want) + } + } +} + func TestScanPrintsReportValidAgainstSchema(t *testing.T) { var out bytes.Buffer - if err := run(context.Background(), []string{"scan", "https://example.com"}, &out, fixedStub()); err != nil { + if err := run(context.Background(), []string{"scan", "https://example.com"}, &out, fixedStub); err != nil { t.Fatalf("run: %v", err) } @@ -86,8 +109,11 @@ func TestScanPrintsReportValidAgainstSchema(t *testing.T) { } func TestScanRejectsBadArgs(t *testing.T) { - for _, args := range [][]string{nil, {"scan"}, {"serve", "https://example.com"}, {"scan", "ftp://example.com"}, {"scan", "example.com"}} { - if err := run(context.Background(), args, &bytes.Buffer{}, fixedStub()); err == nil { + for _, args := range [][]string{ + nil, {"scan"}, {"serve", "https://example.com"}, {"scan", "ftp://example.com"}, {"scan", "example.com"}, + {"scan", "--allow-private"}, {"scan", "--no-such-flag", "https://example.com"}, {"scan", "https://a.example", "https://b.example"}, + } { + if err := run(context.Background(), args, &bytes.Buffer{}, fixedStub); err == nil { t.Errorf("run(%q) = nil error, want error", args) } }