Skip to content

Commit d36e30d

Browse files
roylibmanclaudehisconadaverell
authored
Add get_runtime_versions: declared runtime versions of running images (#2265)
Adds `get_runtime_versions`, an MCP inventory of runtime versions declared by images used by visible Pods. This complements Trivy findings for images built from upstream runtime images, where the runtime may not appear in a package database. Declarations describe image metadata; they do not verify binaries or prove remediation. The tool reads manifests and config blobs for the kubelet-reported digest and node platform, without downloading layers. Curated runtime ENV keys and OCI version/base-image labels are grouped by workload. `runtime` and `fixed_in` rank declarations below the first fixed release on their branch. Only supported upstream version formats are classified; distro revisions and unsupported formats return `unparsed`. Tag/spec fallbacks and assumed platforms receive no fix verdict; partial version declarations remain unparsed. Missing declarations remain visible as `no_declaration` and count as unknown. Invalid runtime filters, image-label fix comparisons and unsupported fixed-release inputs return actionable errors before registry reads. Requests outside the configured namespace pin report that scope explicitly; unfinished credential attempts report not_checked. Callers must be able to list Pods in each namespace. Registry credentials come from the admitted Pods’ own `spec.imagePullSecrets`; Radar’s ambient credentials are used only locally with auth off. Shared mode remains enabled. Node registry credentials are not used, so images pulled only through node identity can remain unreadable; an access failure does not claim that the Pod’s own credentials were rejected. Credentialed lookups require HTTPS for registry requests, redirects and token exchanges. The transport checks destinations and pins connections to allowed addresses, with isolated connection pools per lookup. Only the named registry may use private addresses; link-local destinations are refused. Lookups have a 40-second call budget, 15 seconds per attempt, four concurrent slots, a 200-attempt cap, bounded response bodies and registry backoff. Cached results are separated by credential identity; transient failures expire after 30 seconds. Manifest GETs can consume registry pull quota shared with workloads, including Docker Hub account or egress-IP quotas. The tool is included in the MCP setup catalog and Diagnose allowlist. No new UI is added. Validation: - Focused runtime, registry transport, authorization and catalog tests under `-race`, including HTTP credential fallback, unsupported version comparisons, missing declarations, assumed platforms, input validation, accurate auth errors and transient retry expiry. - `make build` - Visual-test: skipped; no UI delta. Scope: this is an inventory of declarations and remaining unknowns. It does not inspect image filesystems or establish that a vulnerability was remediated. Operator registry policy, resumable pagination and per-caller fairness remain follow-ups. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: hisco <39222286+hisco@users.noreply.github.com> Co-authored-by: Nadav Erell <nadaverell@gmail.com>
1 parent 8c1d740 commit d36e30d

16 files changed

Lines changed: 2758 additions & 3 deletions

File tree

‎docs/mcp.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,7 @@ For `get_cost` and `get_rightsizing`, see [Cost and rightsizing evidence limits]
567567
| `get_workload_logs` | Aggregated logs from all pods of a workload (Deployment, StatefulSet, DaemonSet, Job, Argo Workflow). Without `grep`, auto-filters for diagnostic relevance; with `grep`, returns only matching timestamp-prefixed lines. | `kind` (required), `namespace` (required), `name` (required), `container` (optional), `tail_lines` (optional, default 100 per pod), `grep` (optional) |
568568
| `get_cluster_audit` | `missingInputs`, when present, names unavailable inputs in the scan; zero findings do not establish that all checks passed. Static config posture — best-practice findings (Security / Reliability / Efficiency) with remediation. INDEPENDENT of operational health. Finding severity and the explicit `summary.critical`/`high`/`medium`/`low` counts are posture remediation priority; current built-in checks emit `high` or `medium`. For "what's broken right now?" use `issues`. | `namespace` (optional), `category` (optional), `severity` (optional: `critical`, `high`, `medium`, or `low`) |
569569
| `get_cluster_upgrade_readiness` | Upgrade impact analysis for a target Kubernetes minor — the evidenced check catalog (version skew, removed/deprecated API usage across live manifests / Helm sources / API-server metrics, node runtime + cgroup evidence, drain feasibility, webhook readiness, release-specific config checks). The verdict is not a readiness guarantee: `unknown` checks had incomplete evidence, `coverage` states what the caller's RBAC could actually inspect, and `no_access` returns no verdict at all. Tier 1 is one row per check; `check=<id>` expands into findings with evidence + remediation, paged 25 at a time (`offset` + `scan_id` bind pages to one scan snapshot). Results are memoized ~60s per caller; `refresh=true` re-scans after a fix. Fix findings at the source `evidence.source` names — live objects via `patch_resource`/`apply_resource`, Helm findings in the chart, GitOps-managed resources in Git. | `target` (optional, default next minor), `check` (optional), `level` (optional: `blocker`, `warning`, `review`), `offset` (optional), `scan_id` (required when `offset` > 0), `refresh` (optional) |
570+
| `get_runtime_versions` | Which running workloads use which runtime version, for runtime CVEs (PHP2Shell and the like). For each image a visible Pod runs, Radar reads the image config for the digest the Pod reports and the node's platform (manifest and config only, never layers) and returns the versions it declares: curated keys Docker official images set and images built `FROM` them inherit (`PHP_VERSION`, `NODE_VERSION`, `PYTHON_VERSION`, `GOLANG_VERSION`, `RUBY_VERSION`, `JAVA_VERSION`, `HTTPD_VERSION`, `NGINX_VERSION`, `TOMCAT_VERSION`, `PG_VERSION`, `REDIS_VERSION`, `MONGO_VERSION`, `MARIADB_VERSION`, `MYSQL_VERSION`, `RUST_VERSION`) plus the OCI `version` and `base.name` labels; no other env value is ever read. A declared version is what the image says, not proof of the binary, and runtimes installed from distro packages declare nothing here (Trivy reports cover those; use `search`). `fixed_in` classifies each version against the first fix on its branch (`8.3.36` covers 8.3.x, `17.6` covers 17.x): `declared_below_fix`, `declared_at_or_above_fix`, `branch_not_listed`, `unparsed`. An image `status` other than `ok` (`no_declaration`, `auth_required`, `not_found`, `rate_limited`, `blocked`, `error`, `not_checked`) means unknown, not absent. `resolvedFrom: spec` or `tag` means the kubelet reported no registry digest, so the image named in the Pod spec is shown without a fix verdict; `platformAssumed` means the node's platform wasn't readable and linux/amd64 was used, so no fix verdict is emitted. Missing runtime declarations stay visible as `no_declaration`, counted as unknown; unsupported or distro version formats remain `unparsed`. Only Pods the caller may list are read. Each image is looked up with its Pods' own pull secrets one at a time, then anonymously; node registry credentials are not used, so images pulled only with node identity may remain unreadable; Radar's own registry credentials are used only when Radar runs locally with auth off. A result read with a credential is cached for that credential only, an anonymous one for everyone. Credentialed lookups require HTTPS, including token exchanges and redirects. The call returns within a 40-second lookup budget. Registry use is bounded: at most 4 lookups run at once across callers and 200 attempts per call, 429s are not retried and pause that registry for 5 minutes, configs over 1 MiB are refused, and only the registry a Pod names may sit on a private or (outside the cluster) loopback address; link-local addresses, and redirects or token endpoints elsewhere on internal addresses, are refused. Behind an HTTP proxy, HTTPS goes through a CONNECT tunnel to the checked address; requests that can't be pinned that way are refused when Radar runs in-cluster. `fixed_in` needs `runtime`. Unknown runtime names and unsupported fixed-release formats return input errors. Manifest GETs can consume registry pull quotas shared with workloads, including Docker Hub account or egress-IP limits; a cold inventory can spend multiple manifest requests per image. Transient lookup failures are cached for 30 seconds. | `namespace` (optional), `runtime` (optional, e.g. `php`), `fixed_in` (optional, comma-separated, e.g. `8.2.35,8.3.36,8.4.27,8.5.12`), `limit` (optional, default 50, max 200) |
570571
| `list_packages` | Installed packages (Helm releases, label-managed workloads, CRDs, Argo Applications, Flux HelmReleases + Kustomizations) with source provenance, versions, and health, in one call. Response includes `sourceLegend` for the stable source codes. | `namespace` (optional), `source` (optional: `H`/`helm`, `L`/`labels`, `C`/`crds`, `A`/`argocd`, `F`/`fluxcd`), `chart` (optional substring) |
571572
| `list_helm_releases` | List Helm releases with status, resource health, storage namespace, Flux ownership, current `lastOperation`, and a capped `operations` trail when Helm history indicates failed upgrades, rollback-after-failure, rollbacks, or stuck pending operations. Use this first for Helm deployment debugging. | `namespace` (optional) |
572573
| `get_helm_release` | Returns the release with its owned resources, `hooks` with their outcome, `history` (the newest ten stored revisions; `include=history` returns them all) and the user-supplied `values` (redacted). Detailed Helm release info with owned resources, resource health, Flux ownership, current `lastOperation`, `operationInsight` (active/recovered state, likely resource to inspect, suggested compare), hooks, and failed/running hook diagnostics with live Job/Pod/Event/redacted-log evidence when still available. Use `include=history,operations` for the full Helm revision trail; `include=values` for key-aware redacted user values; `include=diff,values_diff,notes_diff,resource_diff` for revision comparison. Manifest `diff` redacts Secret values while preserving key changes; `notes_diff` uses pattern-based redaction. For releases with `storageNamespace`, pass that value as `namespace`. | `namespace` (required: Helm storage namespace), `name` (required), `include` (optional: `values,history,operations,diff,values_diff,notes_diff,resource_diff`), `diff_revision_1` (required when `include` contains a diff token) / `diff_revision_2` (optional, defaults to current) |

‎internal/images/auth.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,17 @@ func entryToAuthenticator(entry DockerConfigEntry) authn.Authenticator {
365365

366366
return authn.Anonymous
367367
}
368+
369+
// pullSecretIdentity names a pull secret's exact contents: its UID and
370+
// resourceVersion change when it is recreated or rotated.
371+
func pullSecretIdentity(namespace, name string) (string, bool) {
372+
cache := k8s.GetResourceCache()
373+
if cache == nil || cache.Secrets() == nil {
374+
return "", false
375+
}
376+
secret, err := cache.Secrets().Secrets(namespace).Get(name)
377+
if err != nil {
378+
return "", false
379+
}
380+
return string(secret.UID) + "/" + secret.ResourceVersion, true
381+
}
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
package images
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"encoding/base64"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net"
11+
"net/http"
12+
"net/url"
13+
"strings"
14+
"time"
15+
)
16+
17+
// errBlockedAddress marks a registry request refused by the network guard.
18+
var errBlockedAddress = errors.New("registry address not allowed")
19+
20+
// maxRegistryResponseBytes bounds every response body read from a registry
21+
// for a config lookup: manifests, indexes, token responses and the config
22+
// blob. Real ones are kilobytes; this stops a hostile or broken registry from
23+
// streaming an unbounded body into memory.
24+
const maxRegistryResponseBytes = 4 << 20
25+
26+
type registryHostKey struct{}
27+
28+
// namedEndpoint is the registry a lookup is for, as the Pod named it. port is
29+
// empty when the reference had none (the scheme's default port).
30+
type namedEndpoint struct{ host, port string }
31+
32+
// withRegistryHost records the registry a lookup is for. The guard lets that
33+
// endpoint, and only that endpoint, sit on a private or loopback address:
34+
// internal registries are normal, but a redirect or token realm pointing
35+
// somewhere else internal is not.
36+
func withRegistryHost(ctx context.Context, registry string) context.Context {
37+
host, port := splitHostPort(registry)
38+
return context.WithValue(ctx, registryHostKey{}, namedEndpoint{host: host, port: port})
39+
}
40+
41+
// isNamed reports whether host:port is the endpoint the lookup was for.
42+
func isNamed(ctx context.Context, host, port string) bool {
43+
n, _ := ctx.Value(registryHostKey{}).(namedEndpoint)
44+
if n.host == "" || !strings.EqualFold(n.host, host) {
45+
return false
46+
}
47+
if n.port == "" {
48+
return port == "443" || port == "80"
49+
}
50+
return n.port == port
51+
}
52+
53+
// guardedTransport is the transport runtime-version lookups use. Image
54+
// references come from Pod specs, which anyone who can create a Pod
55+
// controls, and Radar's network position differs from a node's. Every
56+
// request, including redirects and token endpoints, is resolved here and
57+
// sent only to an address that passed the policy:
58+
// - link-local (cloud metadata), unspecified and multicast: never;
59+
// - loopback: never in-cluster (it is Radar's own Pod), and locally only
60+
// for the registry endpoint the Pod named (a local dev registry);
61+
// - private ranges: only for the registry endpoint (host and port) the Pod
62+
// named.
63+
//
64+
// Direct connections dial the checked address. Through an HTTP proxy, HTTPS
65+
// requests are tunneled with a CONNECT to the checked address, so the proxy
66+
// can't resolve the name somewhere else. A proxied request that can't be
67+
// pinned that way (plain HTTP, a name that doesn't resolve here, a non-HTTP
68+
// proxy) is refused in-cluster and left to the proxy locally, where the proxy
69+
// is the user's own.
70+
type guardedTransport struct {
71+
requireTLS bool
72+
inCluster bool
73+
proxy func(*http.Request) (*url.URL, error)
74+
resolver *net.Resolver
75+
dialer *net.Dialer
76+
connectTimeout time.Duration
77+
// pinned never uses a proxy on its own; its dialer tunnels through one
78+
// when the request carries a pinned proxy route.
79+
pinned *http.Transport
80+
// unpinned lets the proxy resolve and dial: local mode only.
81+
unpinned *http.Transport
82+
}
83+
84+
// forLookup returns a transport with its own connection pools, for one
85+
// lookup. Pools aren't shared across lookups: a connection one lookup opened
86+
// to the registry it named (maybe on a private address) must never carry
87+
// another lookup's request, even after that name resolves elsewhere.
88+
func (g *guardedTransport) forLookup() *guardedTransport {
89+
return newGuardedTransportWith(g.inCluster, g.proxy, g.resolver)
90+
}
91+
92+
func (g *guardedTransport) closeIdle() {
93+
g.pinned.CloseIdleConnections()
94+
g.unpinned.CloseIdleConnections()
95+
}
96+
97+
func newGuardedTransport(inCluster bool) *guardedTransport {
98+
return newGuardedTransportWith(inCluster, http.ProxyFromEnvironment, net.DefaultResolver)
99+
}
100+
101+
func newGuardedTransportWith(inCluster bool, proxy func(*http.Request) (*url.URL, error), resolver *net.Resolver) *guardedTransport {
102+
g := &guardedTransport{
103+
inCluster: inCluster,
104+
proxy: proxy,
105+
resolver: resolver,
106+
dialer: &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second},
107+
// The CONNECT handshake: request and response headers only.
108+
connectTimeout: 10 * time.Second,
109+
}
110+
newTransport := func() *http.Transport {
111+
return &http.Transport{
112+
ForceAttemptHTTP2: true,
113+
MaxIdleConns: 10,
114+
IdleConnTimeout: 30 * time.Second,
115+
TLSHandshakeTimeout: 10 * time.Second,
116+
ResponseHeaderTimeout: 15 * time.Second,
117+
ExpectContinueTimeout: time.Second,
118+
}
119+
}
120+
g.pinned = newTransport()
121+
g.pinned.DialContext = g.dialContext
122+
g.unpinned = newTransport()
123+
g.unpinned.Proxy = func(r *http.Request) (*url.URL, error) { return g.proxy(r) }
124+
g.unpinned.DialContext = g.dialer.DialContext
125+
return g
126+
}
127+
128+
type proxyRouteKey struct{}
129+
130+
func (g *guardedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
131+
if g.requireTLS && req.URL.Scheme != "https" {
132+
return nil, fmt.Errorf("%w: registry credentials require HTTPS", errBlockedAddress)
133+
}
134+
ctx := req.Context()
135+
host, port := req.URL.Hostname(), req.URL.Port()
136+
if port == "" {
137+
port = map[string]string{"https": "443", "http": "80"}[req.URL.Scheme]
138+
}
139+
proxyURL, err := g.proxy(req)
140+
if err != nil {
141+
proxyURL = nil
142+
}
143+
144+
// Checked per request, not only when dialing: redirects and token
145+
// endpoints are requests too.
146+
ips, lookupErr := g.lookup(ctx, host)
147+
if lookupErr != nil {
148+
if proxyURL != nil && !g.inCluster {
149+
return g.send(g.unpinned, req)
150+
}
151+
if proxyURL != nil {
152+
return nil, fmt.Errorf("%w: %s can't be checked before the proxy resolves it", errBlockedAddress, host)
153+
}
154+
return nil, lookupErr
155+
}
156+
named := isNamed(ctx, host, port)
157+
for _, ip := range ips {
158+
if !registryIPAllowed(ip, g.inCluster, named) {
159+
return nil, fmt.Errorf("%w: %s", errBlockedAddress, host)
160+
}
161+
}
162+
if proxyURL == nil {
163+
return g.send(g.pinned, req)
164+
}
165+
if req.URL.Scheme == "https" && proxyURL.Scheme == "http" {
166+
return g.send(g.pinned, req.WithContext(context.WithValue(ctx, proxyRouteKey{}, proxyURL)))
167+
}
168+
if !g.inCluster {
169+
return g.send(g.unpinned, req)
170+
}
171+
return nil, fmt.Errorf("%w: %s can't be pinned through the proxy", errBlockedAddress, host)
172+
}
173+
174+
func (g *guardedTransport) send(rt *http.Transport, req *http.Request) (*http.Response, error) {
175+
resp, err := rt.RoundTrip(req)
176+
if err != nil || resp.Body == nil {
177+
return resp, err
178+
}
179+
resp.Body = &cappedBody{ReadCloser: resp.Body, left: maxRegistryResponseBytes}
180+
return resp, nil
181+
}
182+
183+
// dialContext connects to the first allowed address of addr, directly or
184+
// through a CONNECT tunnel when the request carries a proxy route. It dials
185+
// the address that was checked, not the name, so a second DNS answer can't
186+
// swap in a forbidden address.
187+
func (g *guardedTransport) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
188+
host, port, err := net.SplitHostPort(addr)
189+
if err != nil {
190+
return nil, err
191+
}
192+
ips, err := g.lookup(ctx, host)
193+
if err != nil {
194+
return nil, err
195+
}
196+
named := isNamed(ctx, host, port)
197+
proxyURL, _ := ctx.Value(proxyRouteKey{}).(*url.URL)
198+
lastErr := fmt.Errorf("%w: %s", errBlockedAddress, host)
199+
for _, ip := range ips {
200+
if !registryIPAllowed(ip, g.inCluster, named) {
201+
continue
202+
}
203+
target := net.JoinHostPort(ip.String(), port)
204+
var conn net.Conn
205+
if proxyURL != nil {
206+
conn, err = g.connectVia(ctx, proxyURL, target)
207+
} else {
208+
conn, err = g.dialer.DialContext(ctx, network, target)
209+
}
210+
if err == nil {
211+
return conn, nil
212+
}
213+
lastErr = err
214+
}
215+
return nil, lastErr
216+
}
217+
218+
// connectVia opens a CONNECT tunnel to target (an IP:port) through an HTTP
219+
// proxy. The proxy itself is configuration, not tenant input, so it isn't
220+
// checked against the address policy.
221+
func (g *guardedTransport) connectVia(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
222+
proxyAddr := proxyURL.Host
223+
if proxyURL.Port() == "" {
224+
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "80")
225+
}
226+
conn, err := g.dialer.DialContext(ctx, "tcp", proxyAddr)
227+
if err != nil {
228+
return nil, err
229+
}
230+
// http.Transport dials without the request's deadline, so the handshake
231+
// gets its own: a proxy that accepts and never answers must not hold the
232+
// connection (and this goroutine) forever.
233+
_ = conn.SetDeadline(time.Now().Add(g.connectTimeout))
234+
req := "CONNECT " + target + " HTTP/1.1\r\nHost: " + target + "\r\n"
235+
if u := proxyURL.User; u != nil {
236+
pass, _ := u.Password()
237+
req += "Proxy-Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(u.Username()+":"+pass)) + "\r\n"
238+
}
239+
if _, err := io.WriteString(conn, req+"\r\n"); err != nil {
240+
_ = conn.Close()
241+
return nil, err
242+
}
243+
br := bufio.NewReader(conn)
244+
// The body of a CONNECT response is never read: after a 200 the bytes
245+
// belong to the tunnel, whatever framing headers the proxy sent.
246+
resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodConnect})
247+
if err != nil {
248+
_ = conn.Close()
249+
return nil, err
250+
}
251+
if resp.StatusCode != http.StatusOK {
252+
_ = conn.Close()
253+
return nil, fmt.Errorf("proxy refused the tunnel: %s", resp.Status)
254+
}
255+
_ = conn.SetDeadline(time.Time{})
256+
if br.Buffered() > 0 {
257+
return &bufferedConn{Conn: conn, r: br}, nil
258+
}
259+
return conn, nil
260+
}
261+
262+
type bufferedConn struct {
263+
net.Conn
264+
r *bufio.Reader
265+
}
266+
267+
func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
268+
269+
func (g *guardedTransport) lookup(ctx context.Context, host string) ([]net.IP, error) {
270+
if ip := net.ParseIP(host); ip != nil {
271+
return []net.IP{ip}, nil
272+
}
273+
addrs, err := g.resolver.LookupIPAddr(ctx, host)
274+
if err != nil {
275+
return nil, err
276+
}
277+
ips := make([]net.IP, 0, len(addrs))
278+
for _, a := range addrs {
279+
ips = append(ips, a.IP)
280+
}
281+
return ips, nil
282+
}
283+
284+
// registryIPAllowed is the address policy; see guardedTransport.
285+
func registryIPAllowed(ip net.IP, inCluster, namedRegistry bool) bool {
286+
switch {
287+
case ip == nil, ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(), ip.IsUnspecified(), ip.IsMulticast():
288+
return false
289+
case ip.IsLoopback():
290+
return !inCluster && namedRegistry
291+
case ip.IsPrivate(), isSharedAddressSpace(ip):
292+
return namedRegistry
293+
}
294+
return true
295+
}
296+
297+
// isSharedAddressSpace reports RFC 6598 carrier-grade NAT space (100.64/10),
298+
// which net.IP.IsPrivate leaves out and some clusters use for Pods.
299+
func isSharedAddressSpace(ip net.IP) bool {
300+
v4 := ip.To4()
301+
return v4 != nil && v4[0] == 100 && v4[1]&0xc0 == 64
302+
}
303+
304+
// splitHostPort splits "host", "host:port", "[v6]" or "[v6]:port".
305+
func splitHostPort(hostport string) (host, port string) {
306+
if h, p, err := net.SplitHostPort(hostport); err == nil {
307+
return h, p
308+
}
309+
return strings.TrimSuffix(strings.TrimPrefix(hostport, "["), "]"), ""
310+
}
311+
312+
type cappedBody struct {
313+
io.ReadCloser
314+
left int64
315+
}
316+
317+
func (b *cappedBody) Read(p []byte) (int, error) {
318+
if b.left <= 0 {
319+
// At the cap: a body that ends exactly here is fine.
320+
var one [1]byte
321+
if n, err := b.ReadCloser.Read(one[:]); n == 0 && err == io.EOF {
322+
return 0, io.EOF
323+
}
324+
return 0, fmt.Errorf("registry response exceeds %d bytes", maxRegistryResponseBytes)
325+
}
326+
if int64(len(p)) > b.left {
327+
p = p[:b.left]
328+
}
329+
n, err := b.ReadCloser.Read(p)
330+
b.left -= int64(n)
331+
return n, err
332+
}
333+
334+
// isBlocked reports whether err came from the network guard.
335+
func isBlocked(err error) bool {
336+
return errors.Is(err, errBlockedAddress)
337+
}

0 commit comments

Comments
 (0)