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.jsonpins 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
-
.credential_header_name(headers) ⇒ Object
The credential header NAME to report for a request, or nil.
-
.credential_header_name?(name) ⇒ Boolean
Whether a header NAME (any casing) is credential-bearing.
-
.disabled_by_env? ⇒ Boolean
KNOXCALL_OBSERVE_UNCOVERED=off|false|0turns the reporter off (read when a pipeline is built). -
.first_segment(url) ⇒ Object
/or/<first path segment>of the URL — never the query, never deeper. -
.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.
- .ip_literal?(host) ⇒ Boolean
-
.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.
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 |