Module: KnoxCall::EgressObservations

Defined in:
lib/knoxcall/egress_observations.rb

Overview

Uncovered-egress observations (PARITY §21.3; founder decisions 2026-09-26). Ruby mirror of sdk/knoxcall-node/src/egress-observations.ts.

A route-aware seam sees every outbound request the process makes and sends only the covered ones through KnoxCall. The rest go direct — and among them are calls that carry a credential the platform does not hold: "uncovered egress". This module records those (host, first path segment, method, credential header NAME) in memory and reports the aggregate to POST /v1/wrap/egress-observations (Resources::Wrap#report_egress_observations), so the dashboard can show a tenant which credentials are still leaving their process un-custodied.

What is recorded is bounded on purpose, and the bound is the feature: names, never values — the credential header's NAME, never its value; the FIRST path segment only — never the query string, never the body, never a deeper path; counts per (host, segment, method, header) with first/last seen. Only a DIRECT decision with reason :unlisted is observed — :own_host, :route_around, :kill_switch, :outside_context and :unparseable never are.

Nothing here may add latency to, raise into, or alter the application's request: KnoxCall::EgressObservationReporter#record is synchronous and cheap, the flush runs on a background thread (Ruby threads never keep a process alive), and every failure is swallowed after one warning. The reporter is process memory only.

Constant Summary collapse

CREDENTIAL_HEADER_ALLOWLIST =

Exact (case-insensitive) header names that carry a credential. The shared fixture sdk/fixtures/egress-observation.json pins this list.

%w[
  authorization proxy-authorization x-api-key api-key apikey x-apikey x-auth-token x-access-token
  x-token token x-secret x-secret-key x-client-secret ocp-apim-subscription-key x-goog-api-key
  x-amz-security-token x-shopify-access-token klaviyo-api-key x-hubspot-api-key
].freeze
CREDENTIAL_HEADER_SUFFIXES =

A lower-cased header name ending in one of these also counts.

%w[-api-key -token -secret -auth].freeze
RANK =
CREDENTIAL_HEADER_ALLOWLIST.each_with_index.to_h.freeze
METHODS =

The methods the server accepts (upper-case); anything else is invalid_method.

%w[GET HEAD POST PUT PATCH DELETE OPTIONS CONNECT TRACE].freeze
HEADER_NAME_RE =

The server's shape checks (src/wrap/egress-observations.ts).

/\A[a-z0-9][a-z0-9_-]*\z/
FIRST_SEGMENT_RE =
%r{\A/[A-Za-z0-9._~!$&'()*+,;=:@%-]{0,255}\z}
MAX_HEADER_NAME_LENGTH =
64
MAX_PLAIN_FIRST_SEGMENT_LENGTH =

A credential in the first path segment (server #1022). Some APIs put one in the path (Telegram's /bot:/...). The server stores such a segment as "/"; the SDK applies the SAME rule before sending.

64
CREDENTIAL_SEGMENT_PREFIXES =
[
  /\Abot\d+:/i,
  /\A(sk|pk|rk)_(live|test)_/i,
  /\Ask-/,
  /\Axox[abposr]-/,
  /\Agh[pousr]_/,
  /\Agithub_pat_/,
  /\Aglpat-/,
  /\Ashp(at|ca|pa|ss)_/,
  /\A(AKIA|ASIA)[0-9A-Z]{12,}/,
  /\AAIza[0-9A-Za-z_-]{20,}/,
  /\AeyJ[A-Za-z0-9_-]{8,}/,
  /\ASG\./
].freeze

Class Method Summary collapse

Class Method Details

.credential_header_name(headers) ⇒ Object

The credential header NAME to report for a request, or nil. Allowlist entries win in allowlist order; then the lexicographically smallest suffix match. A header whose value is empty after stripping never counts. Only names are read — values are looked at solely to discard empties and are never returned. headers is a Hash (any casing; values String or Array) or an Enumerable of [name, value] pairs.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/knoxcall/egress_observations.rb', line 92

def credential_header_name(headers)
  best = nil
  best_rank = CREDENTIAL_HEADER_ALLOWLIST.length + 1
  best_suffix = nil
  (headers || []).each do |raw_name, raw_value|
    name = raw_name.to_s.strip.downcase
    next if name.empty?
    next if Array(raw_value).all? { |v| v.to_s.strip.empty? }

    rank = RANK[name]
    if rank
      if rank < best_rank
        best_rank = rank
        best = name
      end
      next
    end
    next unless credential_header_name?(name)

    best_suffix = name if best_suffix.nil? || name < best_suffix
  end
  best || best_suffix
end

.credential_header_name?(name) ⇒ Boolean

Whether a header NAME (any casing) is credential-bearing.



78
79
80
81
82
83
84
# File 'lib/knoxcall/egress_observations.rb', line 78

def credential_header_name?(name)
  n = name.to_s.strip.downcase
  return false if n.empty? || n.length > MAX_HEADER_NAME_LENGTH || !HEADER_NAME_RE.match?(n)
  return true if RANK.key?(n)

  CREDENTIAL_HEADER_SUFFIXES.any? { |s| n.length > s.length && n.end_with?(s) }
end

.disabled_by_env? ⇒ Boolean

KNOXCALL_OBSERVE_UNCOVERED=off|false|0 turns the reporter off (read when a pipeline is built).



174
175
176
# File 'lib/knoxcall/egress_observations.rb', line 174

def disabled_by_env?
  %w[off 0 false].include?(ENV.fetch("KNOXCALL_OBSERVE_UNCOVERED", "").strip.downcase)
end

.first_segment(url) ⇒ Object

/ or /<first path segment> of the URL — never the query, never deeper. Exactly one leading slash is consumed, so //double reports /.



132
133
134
135
136
137
138
139
# File 'lib/knoxcall/egress_observations.rb', line 132

def first_segment(url)
  path = begin
    URI.parse(url.to_s).path.to_s
  rescue URI::InvalidURIError
    ""
  end
  "/#{path.delete_prefix('/').split('/', 2).first}"
end

.first_segment_looks_like_credential?(first_segment) ⇒ Boolean

Whether a first segment (+/+ + one segment) looks like it carries a credential — the server's rule, on the raw and percent-decoded forms.



118
119
120
121
122
123
124
125
126
127
128
# File 'lib/knoxcall/egress_observations.rb', line 118

def first_segment_looks_like_credential?(first_segment)
  raw = first_segment.to_s.delete_prefix("/")
  return false if raw.empty?

  decoded = raw.gsub(/%\h\h/) { |m| m[1..].hex.chr }.force_encoding(Encoding::UTF_8).scrub
  [raw, decoded].uniq.any? do |s|
    s.length > MAX_PLAIN_FIRST_SEGMENT_LENGTH ||
      CREDENTIAL_SEGMENT_PREFIXES.any? { |re| re.match?(s) } ||
      s.scan(/[A-Za-z0-9_-]{24,}/).any? { |run| [/[a-z]/, /[A-Z]/, /[0-9]/].count { |re| re.match?(run) } >= 2 }
  end
end

.ip_literal?(host) ⇒ Boolean



165
166
167
168
169
170
# File 'lib/knoxcall/egress_observations.rb', line 165

def ip_literal?(host)
  IPAddr.new(host)
  true
rescue IPAddr::Error, ArgumentError
  false
end

.observation_for(url, method, headers) ⇒ Object

The whole classifier, pure: the four identifying fields for this request, or nil when it carries no credential-bearing header. The caller has ALREADY decided the request is direct + :unlisted.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/knoxcall/egress_observations.rb', line 144

def observation_for(url, method, headers)
  host = begin
    WrapTransport.normalize_host(URI.parse(url.to_s).host)
  rescue URI::InvalidURIError
    ""
  end
  return nil if host.nil? || host.empty? || ip_literal?(host) # the server drops it (ip_literal)

  upper = (method.to_s.empty? ? "GET" : method.to_s).upcase
  return nil unless METHODS.include?(upper)

  segment = first_segment(url)
  segment = "/" if first_segment_looks_like_credential?(segment) # reported as "/"; the entry is kept
  return nil unless FIRST_SEGMENT_RE.match?(segment)

  name = credential_header_name(headers)
  return nil if name.nil?

  { host: host, first_segment: segment, method: upper, header_name: name }
end