allstak 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AllStak Ruby SDK sanitizer.
4
+ #
5
+ # Provides recursive scrubbing of sensitive data across the full event surface
6
+ # (user, extras, metadata, breadcrumbs.data, contexts, request, response).
7
+ #
8
+ # Two complementary layers run on the wire path:
9
+ #
10
+ # 1. KEY-NAME redaction (always on): a case-insensitive substring match on
11
+ # Hash keys against the canonical denylist. Conforms to the canonical
12
+ # AllStak SDK denylist defined in docs/standards/sdk-platform-standards.md.
13
+ #
14
+ # 2. VALUE-PATTERN redaction (Sentry data-scrubbing parity): scans free-text
15
+ # *string values* for PII that leaks regardless of key name. Two tiers:
16
+ # A) ALWAYS scrubbed — credit-card numbers that pass the Luhn checksum,
17
+ # and US SSNs written with hyphens. High-risk financial/identity data
18
+ # never legitimately wanted in telemetry.
19
+ # B) Scrubbed UNLESS send_default_pii — email addresses and IPv4
20
+ # addresses. Default send_default_pii=false matches Sentry.
21
+ #
22
+ # Semantics:
23
+ # - Key match: case-insensitive substring match on Hash keys.
24
+ # - Value replacement with the sentinel string `[REDACTED]` (key preserved).
25
+ # - Recursion into Hash, Array; primitive values are passed through (with
26
+ # String values run through the value scrubbers per the rules above).
27
+ # - Cycle protection via an object_id Set.
28
+ # - Structural exemptions: certain keys/subtrees are never value-scrubbed
29
+ # (explicit user object, stack frames, release/sdk fields, URLs/paths,
30
+ # span/operation ids) — see VALUE_SCRUB_SKIP_KEYS / VALUE_SCRUB_SKIP_SUBTREES.
31
+ # - Pure: returns a sanitized copy; never mutates caller-owned structures.
32
+ # - Fail-open: value scrubbing never raises out of {.scrub}; on any error it
33
+ # falls back to the key-redacted-but-not-value-scrubbed structure.
34
+
35
+ require "set"
36
+
37
+ module AllStak
38
+ module Sanitizer
39
+ REDACTED = "[REDACTED]"
40
+
41
+ DEFAULT_DENYLIST = %w[
42
+ authorization
43
+ proxy-authorization
44
+ cookie
45
+ set-cookie
46
+ password
47
+ passwd
48
+ pwd
49
+ api_key
50
+ apikey
51
+ x-api-key
52
+ x-allstak-key
53
+ x-auth-token
54
+ x-access-token
55
+ token
56
+ bearer
57
+ jwt
58
+ session
59
+ sessionid
60
+ session_id
61
+ secret
62
+ credit_card
63
+ card_number
64
+ cvv
65
+ ssn
66
+ csrf
67
+ ].freeze
68
+
69
+ # Exact, CASE-SENSITIVE keys that look sensitive by substring but are NOT —
70
+ # they are first-class SDK telemetry fields that must survive scrubbing.
71
+ # The release-health `sessionId` (camelCase) carries the SDK's own
72
+ # per-process session id (a random UUID, not a user/auth session token);
73
+ # the backend error consumer needs it to attribute crashes, so it must
74
+ # never be redacted. Matched exactly and case-sensitively, so genuine
75
+ # cookie/auth keys like `session`, `session_id`, or `sessionid` (the
76
+ # lower-case denylist terms) are still scrubbed.
77
+ ALLOWLIST = %w[
78
+ sessionId
79
+ ].freeze
80
+
81
+ # --- value-pattern scrubbing configuration -----------------------------
82
+
83
+ # Longest single string we will scan for value patterns. Larger strings are
84
+ # passed through untouched so a pathological multi-MB blob never stalls the
85
+ # wire path. Key-name redaction still applies to its containing key.
86
+ MAX_SCAN_LENGTH = 16_384
87
+
88
+ # Keys whose *scalar* string value is exempt from value-pattern scrubbing
89
+ # (matched case-sensitively against the original key, then case-insensitively
90
+ # as a fallback). These carry structured identifiers / locations that the
91
+ # patterns would otherwise corrupt: stack-frame fields, release/sdk/build
92
+ # metadata, span & trace ids, URLs/paths (their own URL redactor owns them).
93
+ VALUE_SCRUB_SKIP_KEYS = %w[
94
+ filename
95
+ function
96
+ abspath
97
+ abs_path
98
+ lineno
99
+ colno
100
+ release
101
+ version
102
+ dist
103
+ platform
104
+ environment
105
+ sdkname
106
+ sdk_name
107
+ sdkversion
108
+ sdk_version
109
+ sdk.name
110
+ sdk.version
111
+ commit.sha
112
+ commit.branch
113
+ commit_sha
114
+ url
115
+ path
116
+ host
117
+ hostname
118
+ route
119
+ operation
120
+ op
121
+ spanid
122
+ span_id
123
+ parentspanid
124
+ parent_span_id
125
+ traceid
126
+ trace_id
127
+ requestid
128
+ request_id
129
+ sessionid
130
+ sessionId
131
+ timestamp
132
+ ].each_with_object({}) { |k, h| h[k.downcase] = true }.freeze
133
+
134
+ # Top-level subtrees that are never value-scrubbed. `user` holds data the
135
+ # caller explicitly set via setUser (intentional identification — ships as
136
+ # before, matching Sentry). `frames`/`stackTrace` hold structured stack
137
+ # frames whose filenames/functions must not be corrupted.
138
+ VALUE_SCRUB_SKIP_SUBTREES = %w[
139
+ user
140
+ frames
141
+ stackTrace
142
+ stacktrace
143
+ ].each_with_object({}) { |k, h| h[k.downcase] = true }.freeze
144
+
145
+ # US SSN — REQUIRE the hyphens so bare 9-digit numbers (order ids, etc.)
146
+ # are not nuked. Compiled once.
147
+ SSN_REGEX = /\b\d{3}-\d{2}-\d{4}\b/.freeze
148
+
149
+ # Candidate credit-card runs: 13–19 digits with optional single space/hyphen
150
+ # separators between groups. Luhn-validated before redaction (see #luhn?),
151
+ # so digit runs that fail the checksum (timestamps, order ids) survive.
152
+ # Word-boundary-ish anchors keep us from matching the middle of a longer
153
+ # digit string.
154
+ CC_CANDIDATE_REGEX = /(?<![\d-])(?:\d[ -]?){12,18}\d(?![\d-])/.freeze
155
+
156
+ # Standard email address. Compiled once.
157
+ EMAIL_REGEX = /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/.freeze
158
+
159
+ # IPv4 with each octet validated to 0–255. Compiled once.
160
+ IPV4_OCTET = '(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)'
161
+ IPV4_REGEX = /\b#{IPV4_OCTET}\.#{IPV4_OCTET}\.#{IPV4_OCTET}\.#{IPV4_OCTET}\b/.freeze
162
+
163
+ # IPv6 best-effort: 2+ groups of hex separated by colons, with optional ::
164
+ # compression. Intentionally loose — IPv6 detection is best-effort per spec.
165
+ IPV6_REGEX = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{0,4}\b|\b::(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4}\b/.freeze
166
+
167
+ module_function
168
+
169
+ # Returns a sanitized deep copy of `payload`.
170
+ #
171
+ # @param extra_denylist [Array<String>, nil] additional key terms to redact;
172
+ # may extend but not narrow the canonical list.
173
+ # @param send_default_pii [Boolean] when true, the tier-B value scrubbers
174
+ # (email, IPv4/IPv6) are disabled — the caller has opted into PII. Tier-A
175
+ # (credit card, SSN) is ALWAYS applied. Default false (Sentry parity).
176
+ # @param values [Boolean] when false, only key-name redaction runs (no
177
+ # value-pattern scrubbing). Useful for an intermediate pre-scrub (e.g.
178
+ # Sidekiq job args) where the wire-path scrub will value-scrub later with
179
+ # the authoritative config. Default true.
180
+ def scrub(payload, extra_denylist: nil, send_default_pii: false, values: true)
181
+ denylist = DEFAULT_DENYLIST.dup
182
+ denylist.concat(extra_denylist.map { |t| t.to_s.downcase }) if extra_denylist
183
+ denylist.uniq!
184
+ return walk_keys_only(payload, denylist, Set.new) unless values
185
+ walk(payload, denylist, Set.new, send_default_pii)
186
+ end
187
+
188
+ def sensitive?(key, denylist)
189
+ return false unless key.is_a?(String) || key.is_a?(Symbol)
190
+
191
+ # Exact, case-sensitive allowlist wins: a first-class SDK field (e.g.
192
+ # release-health `sessionId`) is never scrubbed even though its lowercase
193
+ # form contains a denied substring. Checked against the ORIGINAL key so
194
+ # `sessionId` survives while `sessionid`/`session_id`/`session` are scrubbed.
195
+ return false if ALLOWLIST.include?(key.to_s)
196
+
197
+ k = key.to_s.downcase
198
+ denylist.any? { |term| k.include?(term) }
199
+ end
200
+
201
+ def walk(value, denylist, seen, send_default_pii)
202
+ case value
203
+ when Hash
204
+ return REDACTED if seen.include?(value.object_id)
205
+
206
+ seen.add(value.object_id)
207
+ value.each_with_object({}) do |(k, v), out|
208
+ out[k] =
209
+ if sensitive?(k, denylist)
210
+ REDACTED
211
+ elsif skip_subtree?(k)
212
+ # Explicit user object / stack frames: deep-copy with key-name
213
+ # redaction still applied, but NO value-pattern scrubbing.
214
+ walk_keys_only(v, denylist, seen)
215
+ elsif skip_value_scrub_key?(k)
216
+ # Structured scalar (release, url, span id, …): recurse for nested
217
+ # collections, but do not value-scrub a scalar string here.
218
+ v.is_a?(Hash) || v.is_a?(Array) ? walk(v, denylist, seen, send_default_pii) : v
219
+ else
220
+ walk(v, denylist, seen, send_default_pii)
221
+ end
222
+ end
223
+ when Array
224
+ return REDACTED if seen.include?(value.object_id)
225
+
226
+ seen.add(value.object_id)
227
+ value.map { |v| walk(v, denylist, seen, send_default_pii) }
228
+ when String
229
+ scrub_value(value, send_default_pii)
230
+ else
231
+ value
232
+ end
233
+ end
234
+
235
+ # Recurse applying ONLY key-name redaction (no value-pattern scrubbing).
236
+ # Used for exempt subtrees (explicit user object, stack frames).
237
+ def walk_keys_only(value, denylist, seen)
238
+ case value
239
+ when Hash
240
+ return REDACTED if seen.include?(value.object_id)
241
+
242
+ seen.add(value.object_id)
243
+ value.each_with_object({}) do |(k, v), out|
244
+ out[k] = sensitive?(k, denylist) ? REDACTED : walk_keys_only(v, denylist, seen)
245
+ end
246
+ when Array
247
+ return REDACTED if seen.include?(value.object_id)
248
+
249
+ seen.add(value.object_id)
250
+ value.map { |v| walk_keys_only(v, denylist, seen) }
251
+ else
252
+ value
253
+ end
254
+ end
255
+
256
+ def skip_subtree?(key)
257
+ return false unless key.is_a?(String) || key.is_a?(Symbol)
258
+ VALUE_SCRUB_SKIP_SUBTREES.key?(key.to_s.downcase)
259
+ end
260
+
261
+ def skip_value_scrub_key?(key)
262
+ return false unless key.is_a?(String) || key.is_a?(Symbol)
263
+ VALUE_SCRUB_SKIP_KEYS.key?(key.to_s.downcase)
264
+ end
265
+
266
+ # Apply value-pattern scrubbing to a single string. Fail-open: any error
267
+ # returns the original string. Oversized strings are passed through.
268
+ def scrub_value(str, send_default_pii)
269
+ return str unless str.is_a?(String)
270
+ return str if str.empty? || str.length > MAX_SCAN_LENGTH
271
+
272
+ out = str
273
+
274
+ # Tier A — ALWAYS (regardless of send_default_pii).
275
+ out = out.gsub(SSN_REGEX, REDACTED)
276
+ out = scrub_credit_cards(out)
277
+
278
+ # Tier B — only when the caller has NOT opted into PII.
279
+ unless send_default_pii
280
+ out = out.gsub(EMAIL_REGEX, REDACTED)
281
+ out = out.gsub(IPV4_REGEX, REDACTED)
282
+ out = out.gsub(IPV6_REGEX, REDACTED)
283
+ end
284
+
285
+ out
286
+ rescue StandardError
287
+ str
288
+ end
289
+
290
+ # Replace only those candidate credit-card runs that pass the Luhn checksum.
291
+ # A run that fails Luhn (e.g. an order id or timestamp that happens to be
292
+ # 13–19 digits) is left intact, minimizing over-redaction.
293
+ def scrub_credit_cards(str)
294
+ str.gsub(CC_CANDIDATE_REGEX) do |match|
295
+ digits = match.gsub(/[ -]/, "")
296
+ if digits.length.between?(13, 19) && luhn?(digits)
297
+ REDACTED
298
+ else
299
+ match
300
+ end
301
+ end
302
+ end
303
+
304
+ # Luhn (mod-10) checksum over a string of digits.
305
+ def luhn?(digits)
306
+ return false unless digits =~ /\A\d{13,19}\z/
307
+
308
+ sum = 0
309
+ double = false
310
+ digits.reverse.each_char do |ch|
311
+ d = ch.to_i
312
+ if double
313
+ d *= 2
314
+ d -= 9 if d > 9
315
+ end
316
+ sum += d
317
+ double = !double
318
+ end
319
+ (sum % 10).zero?
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,216 @@
1
+ require "securerandom"
2
+ require_relative "transport/http_transport"
3
+
4
+ module AllStak
5
+ # Server-mode "single session" release-health tracker.
6
+ #
7
+ # Mirrors the AllStak Java SDK `SessionTracker` lifecycle + status model:
8
+ # on {#start} the SDK posts a `/ingest/v1/sessions/start` envelope with the
9
+ # process's distinct session id, the resolved release, and SDK identity. On
10
+ # {#end} it posts `/ingest/v1/sessions/end` with the final status + total
11
+ # duration. ERRORED / CRASHED transitions are recorded in-memory only; only
12
+ # the terminal {#end} call performs network I/O for status, so per-error
13
+ # latency stays unaffected.
14
+ #
15
+ # One instance per {AllStak::Client}. Re-entrancy safe: once started a second
16
+ # {#start} is a no-op; once ended the tracker does not re-arm.
17
+ #
18
+ # Sessions are NEVER sampled — they are always sent (when tracking is on and a
19
+ # release is resolvable). The whole tracker is fully fail-open: a network
20
+ # failure or any other error must never crash app boot or shutdown.
21
+ class SessionTracker
22
+ PATH_START = "/ingest/v1/sessions/start".freeze
23
+ PATH_END = "/ingest/v1/sessions/end".freeze
24
+
25
+ # Lifecycle status. Vocabulary matches the backend `/sessions/end` contract
26
+ # and Sentry's release-health conventions:
27
+ # ok — ended normally, at most non-fatal logs.
28
+ # errored — at least one HANDLED error captured; process kept running.
29
+ # crashed — an UNHANDLED/fatal exception ended the process.
30
+ # abnormal — ended without a normal flush (reserved).
31
+ STATUS_OK = "ok".freeze
32
+ STATUS_ERRORED = "errored".freeze
33
+ STATUS_CRASHED = "crashed".freeze
34
+ STATUS_ABNORMAL = "abnormal".freeze
35
+
36
+ attr_reader :session_id, :started_at
37
+
38
+ def initialize(config, transport, logger = nil)
39
+ @config = config
40
+ @transport = transport
41
+ @logger = logger
42
+ @mutex = Mutex.new
43
+ @session_id = nil
44
+ @started_at = nil
45
+ @status = STATUS_OK
46
+ @error_count = 0
47
+ @started = false
48
+ @ended = false
49
+ end
50
+
51
+ # Should this runtime track sessions at all? Off when the user opted out via
52
+ # `enable_auto_session_tracking = false`, and automatically off under a unit
53
+ # test runtime (mirrors the Java SDK's test guard) so the suite never emits
54
+ # session traffic.
55
+ def enabled?
56
+ return false unless @config.enable_auto_session_tracking
57
+ !self.class.test_runtime?
58
+ end
59
+
60
+ # Detect a unit-test runtime so session tracking self-disables there,
61
+ # matching {AllStak.register_runtime_release}'s own guard.
62
+ def self.test_runtime?
63
+ return true if ENV["MT_TEST"]
64
+ return true if ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test"
65
+ return true if ENV["RUBYOPT"].to_s.include?("minitest")
66
+ return true if $PROGRAM_NAME.to_s.include?("rspec")
67
+ defined?(Minitest) ? true : false
68
+ end
69
+
70
+ # Idempotent. Records sessionStart, sets in-memory status = "ok", and POSTs
71
+ # `/sessions/start` on a daemon thread so SDK init never blocks on a network
72
+ # round-trip. No-op when tracking is disabled, the transport is disabled, or
73
+ # no release/sdkVersion can be resolved. Never raises.
74
+ def start
75
+ @mutex.synchronize do
76
+ return self if @started
77
+ @started = true
78
+ @session_id = SecureRandom.uuid
79
+ @started_at = now_ms
80
+ @status = STATUS_OK
81
+ @error_count = 0
82
+ end
83
+
84
+ return self unless enabled?
85
+ return self if transport_disabled?
86
+
87
+ release = effective_release
88
+ return self if release.to_s.empty?
89
+
90
+ payload = {
91
+ sessionId: @session_id,
92
+ release: release,
93
+ environment: @config.environment,
94
+ userId: current_user_id,
95
+ sdkName: @config.sdk_name,
96
+ sdkVersion: @config.sdk_version,
97
+ platform: @config.platform
98
+ }.compact
99
+
100
+ send_async(PATH_START, payload, "session start")
101
+ self
102
+ end
103
+
104
+ # The active session id, or nil before start / after end. Attached to every
105
+ # error/event payload so the backend can mark the session errored/crashed.
106
+ def current_session_id
107
+ @mutex.synchronize { (@started && !@ended) ? @session_id : nil }
108
+ end
109
+
110
+ # Record a HANDLED error: bump status ok -> errored (never downgrades a
111
+ # terminal crash). No I/O.
112
+ def record_error
113
+ @mutex.synchronize do
114
+ next unless active_locked?
115
+ @error_count += 1
116
+ @status = STATUS_ERRORED if @status == STATUS_OK
117
+ end
118
+ end
119
+
120
+ # Record an UNHANDLED/fatal crash: terminal status (overrides errored).
121
+ # No I/O — the {#end} POST carries the status.
122
+ def record_crash
123
+ @mutex.synchronize do
124
+ next unless active_locked?
125
+ @error_count += 1
126
+ @status = STATUS_CRASHED
127
+ end
128
+ end
129
+
130
+ # Terminate the session and POST `/sessions/end` with durationMs + status.
131
+ # Idempotent. Best-effort with a short timeout; must not block or raise.
132
+ # `final_status` overrides the accumulated status when given.
133
+ def end(final_status = nil)
134
+ sid = nil
135
+ status = nil
136
+ duration = nil
137
+ @mutex.synchronize do
138
+ return if @ended || !@started
139
+ @ended = true
140
+ sid = @session_id
141
+ status = final_status || @status
142
+ duration = [now_ms - @started_at.to_i, 0].max
143
+ end
144
+
145
+ return unless enabled?
146
+ return if transport_disabled?
147
+ return if effective_release.to_s.empty?
148
+
149
+ payload = {
150
+ sessionId: sid,
151
+ durationMs: clamp_int(duration),
152
+ status: status
153
+ }.compact
154
+
155
+ send_sync(PATH_END, payload, "session end")
156
+ end
157
+
158
+ private
159
+
160
+ def active_locked?
161
+ @started && !@ended
162
+ end
163
+
164
+ def now_ms
165
+ (Time.now.to_f * 1000).to_i
166
+ end
167
+
168
+ def clamp_int(value)
169
+ v = value.to_i
170
+ v > 2_147_483_647 ? 2_147_483_647 : v
171
+ end
172
+
173
+ # Release is REQUIRED by the backend; fall back to the SDK version when no
174
+ # release is resolved so release-health attribution still has a key.
175
+ def effective_release
176
+ rel = @config.release
177
+ rel = @config.sdk_version if rel.to_s.empty?
178
+ rel
179
+ end
180
+
181
+ def current_user_id
182
+ uid = @config.respond_to?(:user_id) ? @config.user_id : nil
183
+ uid.to_s.empty? ? nil : uid.to_s
184
+ end
185
+
186
+ def transport_disabled?
187
+ @transport.respond_to?(:disabled?) && @transport.disabled?
188
+ rescue StandardError
189
+ false
190
+ end
191
+
192
+ # POST off the hot/boot path on a daemon thread. Fail-open.
193
+ def send_async(path, payload, label)
194
+ thread = Thread.new do
195
+ begin
196
+ @transport.post(path, payload)
197
+ @logger&.debug("[AllStak] #{label}: #{payload[:sessionId]}")
198
+ rescue StandardError => e
199
+ @logger&.debug("[AllStak] #{label} failed: #{e.class}: #{e.message}")
200
+ end
201
+ end
202
+ thread.abort_on_exception = false
203
+ rescue StandardError => e
204
+ @logger&.debug("[AllStak] #{label} could not start: #{e.class}: #{e.message}")
205
+ end
206
+
207
+ # Synchronous best-effort POST for shutdown — the process may exit before a
208
+ # background thread runs, so end is sent inline. Never raises.
209
+ def send_sync(path, payload, label)
210
+ @transport.post(path, payload)
211
+ @logger&.debug("[AllStak] #{label}: #{payload[:sessionId]} status=#{payload[:status]}")
212
+ rescue StandardError => e
213
+ @logger&.debug("[AllStak] #{label} failed: #{e.class}: #{e.message}")
214
+ end
215
+ end
216
+ end