closeyourit-ruby 0.9.3 → 0.10.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7fb813ea37f0fb206ed31a5a960f795e364c6b23902d59b9b5d6e56ff4fb295
4
- data.tar.gz: 926f4e714781796b43ad8c9eaf0e6bf52d7976599a188d3e3720637df623d2bb
3
+ metadata.gz: 0bc659e436ad9b08b9861d4898e6876a3b753ec88ebb7f8e81075729d958fcb4
4
+ data.tar.gz: 2bb509651ce4aa880a40f2d49f29c9584bb1bfde984b6fba3065c1a954800f82
5
5
  SHA512:
6
- metadata.gz: d8d590e358c098c3061383a85f9e1593b0b574a0ee52fcb67bbdd76e215cf44076703d9a1917d52acc99e43dee0ba58113afd9516c44b1adaa807341c22cf3ee
7
- data.tar.gz: e492934a7b0329fe0ba887dc45b05fa8c148ac3fb9665d866f95bdfe25b1cd114aa66bf95bca76cb83f3f16a96a34b4b24165152a247971e43888358e69291f9
6
+ metadata.gz: d3cfd52ac4b7ddda5a5a92485ebe1fb4be25b808e706b11af7c5e0c6767fff19af3aa3958a8a3d9e6d94ec6ec36162a942fa439700bd16d07d52987d34a4328e
7
+ data.tar.gz: fe22c4cae5a0e94f4eeaf8284d00c0a42d51a0d8c3830dcd703abcf9774eaf31c4e5e81c0c4b8ba6331a1e3d331078136b2cf05e09316325c5f545acaa8b9c17
data/README.md CHANGED
@@ -79,6 +79,7 @@ end
79
79
  | `before_send` | `nil` | `->(payload) { ... }` — scrub finale; ritorna payload o `nil` per scartare |
80
80
  | `sample_rate` | `1.0` | Frazione di errori/messaggi inviata (`1.0` tutto, `0.0` niente) |
81
81
  | `async_threads` | `cpu/2` | Thread di invio; `0` = sincrono (test) |
82
+ | `shutdown_timeout` | `15` | Secondi di attesa, alla chiusura, perché gli invii in volo completino; scaduto il tempo gli eventi residui sono contati come `dropped` (vedi [Flush di fine-vita](#flush-di-fine-vita)) |
82
83
  | `trap_signals` | `false` | Intercetta `SIGTERM` per garantire il flush di fine-vita (deploy/Kamal); opt-in perché sovrascrive un eventuale handler `TERM` dell'app ospite (vedi [Flush di fine-vita](#flush-di-fine-vita)) |
83
84
  | `slow_query_threshold_ms` | `100` | Soglia query lente |
84
85
  | `excluded_query_patterns` | `solid_queue_`, `solid_cache_`, `solid_cable_` | Query da NON misurare come rallentamento — **Regexp** (pattern) o **String** (testo letterale). Vale solo per la misura: breadcrumb e profiling N+1 vedono tutto |
@@ -92,7 +93,7 @@ end
92
93
  | `send_pii` | `false` | Master switch PII |
93
94
  | `obfuscate_sql` | `true` | Maschera i literal nello SQL |
94
95
  | `filter_parameters` | `[]` | Chiavi extra da redarre (mergiate con quelle di Rails) |
95
- | `scrub_message_patterns` | `[]` | Regexp da redarre da messaggi d'eccezione **e da `message` dei log** |
96
+ | `scrub_message_patterns` | `[]` | Regexp **aggiuntive** da redarre da messaggi d'eccezione **e da `message` dei log**; le credenziali `Bearer`/`Basic`/`Token` sono già redatte di serie |
96
97
  | `logs_enabled` | `true` | Master switch dello stream di log strutturato |
97
98
  | `logs_min_level` | `:info` | Soglia minima: i log sotto (es. `debug`) non vengono inviati |
98
99
  | `logs_sample_rate` | `1.0` | Frazione di log inviata (`1.0` tutto, `0.0` niente) |
@@ -227,6 +228,11 @@ buffer), così i log sotto-batch di rake/CLI non vanno persi all'uscita. `SIGTER
227
228
  termina il processo **senza** eseguire gli `at_exit`: imposta `trap_signals = true` per intercettarlo e
228
229
  convertirlo in un exit pulito, oppure chiama `CloseYourIt.shutdown` esplicitamente prima di terminare.
229
230
 
231
+ Il drain attende fino a `shutdown_timeout` secondi (default `15`, il tetto di durata di una singola
232
+ POST verso l'ingest). Gli eventi ancora in coda quando il tempo scade sono contati come `dropped`
233
+ nello snapshot diagnostico di fine-vita: nessuna perdita silenziosa. Abbassa il valore se l'uscita
234
+ del processo deve essere più rapida della consegna.
235
+
230
236
  #### Server che forkano (Puma cluster, Sidekiq, Unicorn)
231
237
 
232
238
  I server che precaricano l'app (`preload_app!`) inizializzano la gemma nel processo **master**, poi
@@ -381,8 +387,10 @@ Privacy-by-default (`send_pii = false`). In sintesi:
381
387
  - **SQL**: inviato il template, **mai** i bind values; `obfuscate_sql` maschera anche i literal inline.
382
388
  - **Chiavi sensibili** (`password`, `token`, `authorization`, `cookie`, `secret`, `api_key`, `csrf`,
383
389
  `credit_card`, `cvv`, `ssn`, `iban`, …) → `[FILTERED]`; estendibili con `filter_parameters`.
384
- - **Messaggi d'eccezione**: inviati per il debug, ma redigibili con `scrub_message_patterns` /
385
- `before_send`.
390
+ - **Messaggi d'eccezione**: inviati per il debug. Le credenziali di autenticazione finite nel testo
391
+ (`Authorization: Bearer …`, `Basic …`, `Token …`) sono redatte **di serie**, senza configurazione:
392
+ chiave e schema restano leggibili, sparisce il valore → `Authorization: Bearer [FILTERED]`. Il resto
393
+ è redigibile con `scrub_message_patterns` / `before_send`.
386
394
  - **Mai inviati**: variabili locali dei frame, argomenti dei metodi, IP/cookie/Authorization, token (che
387
395
  viaggia solo nell'header su HTTPS).
388
396
 
@@ -32,15 +32,34 @@ module CloseYourIt
32
32
  accepted
33
33
  end
34
34
 
35
- def shutdown(timeout = 1)
35
+ # Drena la coda attendendo fino a `timeout` secondi. Il default è il tetto di durata di UNA POST
36
+ # (Transport::MAX_REQUEST_SECONDS): aspettare meno abbandona invii ancora sani, ed è quello che
37
+ # succedeva con il vecchio secondo fisso (CYRB-24). Se il drain scade, i task rimasti in coda sono
38
+ # persi: contali come `dropped`, altrimenti lo snapshot di fine vita dichiara zero perdite.
39
+ def shutdown(timeout = Transport::MAX_REQUEST_SECONDS)
36
40
  return unless @executor.respond_to?(:shutdown)
37
41
 
38
42
  @executor.shutdown
39
- @executor.wait_for_termination(timeout)
43
+ terminated = @executor.wait_for_termination(timeout)
44
+ report_abandoned(timeout) unless terminated
45
+ terminated
40
46
  end
41
47
 
42
48
  private
43
49
 
50
+ # Task accodati e mai eseguiti al momento della scadenza del drain. `queue_length` non esiste
51
+ # sull'ImmediateExecutor (sincrono, niente coda) → zero residui.
52
+ def report_abandoned(timeout)
53
+ abandoned = @executor.respond_to?(:queue_length) ? @executor.queue_length.to_i : 0
54
+ CloseYourIt.internal_logger.warn(
55
+ "CloseYourIt background worker: drain scaduto dopo #{timeout}s, #{abandoned} eventi abbandonati"
56
+ )
57
+ abandoned.times do
58
+ CloseYourIt.stats.increment(:dropped)
59
+ CloseYourIt.notify_diagnostic(:drop, reason: :shutdown_timeout)
60
+ end
61
+ end
62
+
44
63
  def build_executor(threads, max_queue)
45
64
  return Concurrent::ImmediateExecutor.new if threads <= 0
46
65
 
@@ -54,17 +54,7 @@ module CloseYourIt
54
54
  def flush_logs(events)
55
55
  return nil if events.nil? || events.empty?
56
56
 
57
- payloads = events.map(&:to_h)
58
- if @configuration.before_send
59
- kept = payloads.filter_map { |payload| @configuration.before_send.call(payload) }
60
- # I log che before_send porta a nil sono scarti voluti: contabilizzali come gli errori/metriche
61
- # (parità con #capture_event), altrimenti sparirebbero silenziosamente dai contatori (CYRB-12).
62
- (payloads.size - kept.size).times do
63
- CloseYourIt.stats.increment(:dropped)
64
- CloseYourIt.notify_diagnostic(:drop, reason: :before_send)
65
- end
66
- payloads = kept
67
- end
57
+ payloads = events.filter_map { |event| build_log_payload(event) }
68
58
  return nil if payloads.empty?
69
59
 
70
60
  path = events.first.ingest_path(@configuration.project_id)
@@ -95,7 +85,30 @@ module CloseYourIt
95
85
  end
96
86
 
97
87
  def shutdown
98
- @worker.shutdown
88
+ @worker.shutdown(@configuration.shutdown_timeout)
89
+ end
90
+
91
+ private
92
+
93
+ # Costruisce il payload di UNA voce di log. `to_h` (scrubber su un byte non UTF-8) e `before_send`
94
+ # possono sollevare: qui il buffer è già stato drenato, quindi una sola voce rotta valutata
95
+ # insieme alle altre porterebbe via l'intero batch di voci sane. Ogni voce è isolata e scartata
96
+ # da sola, come fa il client JS (CYRB-24). `nil` = voce da non spedire, già contabilizzata.
97
+ def build_log_payload(event)
98
+ payload = event.to_h
99
+ payload = @configuration.before_send.call(payload) if @configuration.before_send
100
+ return payload unless payload.nil?
101
+
102
+ # Scarto voluto di before_send: contabilizzato come in #capture_event, altrimenti sparirebbe
103
+ # silenziosamente dai contatori (CYRB-12).
104
+ CloseYourIt.stats.increment(:dropped)
105
+ CloseYourIt.notify_diagnostic(:drop, reason: :before_send)
106
+ nil
107
+ rescue StandardError => e
108
+ CloseYourIt.internal_logger.error("CloseYourIt client: log scartato — #{e.class}: #{e.message}")
109
+ CloseYourIt.stats.increment(:dropped)
110
+ CloseYourIt.notify_diagnostic(:drop, reason: :error, error: e.class.name)
111
+ nil
99
112
  end
100
113
  end
101
114
  end
@@ -27,7 +27,7 @@ module CloseYourIt
27
27
  ].freeze
28
28
 
29
29
  attr_accessor :endpoint_url, :token, :project_id, :environment, :before_send, :on_diagnostic,
30
- :async_threads, :background_worker_max_queue,
30
+ :async_threads, :background_worker_max_queue, :shutdown_timeout,
31
31
  :slow_query_threshold_ms, :slow_method_threshold_ms,
32
32
  :send_pii, :obfuscate_sql, :send_server_name,
33
33
  :capture_query_bindings, :capture_method_arguments,
@@ -65,6 +65,10 @@ module CloseYourIt
65
65
 
66
66
  @async_threads = default_threads
67
67
  @background_worker_max_queue = 30
68
+ # Quanto attendere, alla chiusura del processo, che gli invii in volo completino. Default: il
69
+ # tetto di durata di UNA POST, così l'ultimo batch non viene abbandonato a metà (CYRB-24).
70
+ # Abbassalo se l'uscita del processo deve essere più rapida della consegna.
71
+ @shutdown_timeout = Transport::MAX_REQUEST_SECONDS
68
72
 
69
73
  # Intercetta SIGTERM per garantire il flush di fine-vita (deploy/Kamal): SIGTERM di default
70
74
  # termina il processo SENZA eseguire gli at_exit. OPT-IN perché sovrascrive un eventuale handler
@@ -13,8 +13,16 @@ module CloseYourIt
13
13
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
14
  yield
15
15
  ensure
16
- duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
17
- report(label, duration_ms, location, args: args, kwargs: kwargs)
16
+ # `measure` gira DENTRO il metodo dell'app (Monitor fa prepend): un guasto qui — soglia non
17
+ # configurata, ingest irraggiungibile solleverebbe in un chiamante che aveva già il suo
18
+ # risultato. È l'unico ingresso della gemma che era scoperto: ora è come tutti gli altri
19
+ # (CYRB-24). Un'eccezione del blocco misurato continua invece a propagare intatta.
20
+ begin
21
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
22
+ report(label, duration_ms, location, args: args, kwargs: kwargs)
23
+ rescue StandardError => e
24
+ CloseYourIt.internal_logger.error("CloseYourIt instrumenter: #{e.class}: #{e.message}")
25
+ end
18
26
  end
19
27
 
20
28
  def report(label, duration_ms, location = nil, args: nil, kwargs: nil)
@@ -23,6 +23,53 @@ module CloseYourIt
23
23
  email phone telephone mobile dob birth passport bearer session pin pan
24
24
  ].freeze
25
25
 
26
+ # CYRB-23 — redazione INTEGRATA delle credenziali di autenticazione nel testo libero.
27
+ # `filter_params`/`filter_value` coprono gli header STRUTTURATI (la chiave `authorization` è in
28
+ # DENYLIST); un `Authorization: Bearer <token>` incollato dentro `exception.message` o dentro la
29
+ # riga di un log è testo, non una chiave, e prima di questo ticket partiva in chiaro perché
30
+ # `scrub_message` applicava solo i pattern configurati dall'utente (default `[]`).
31
+ #
32
+ # Forma del testo redatto (decisa qui una volta per tutti gli SDK — gemelli CYDA-26/CYJS-38/
33
+ # CYPY-15): chiave e schema restano leggibili, sparisce SOLO la credenziale →
34
+ # `Authorization: Bearer [FILTERED]`. Il messaggio resta diagnosticamente utile (si vede CHE
35
+ # c'era un header e con quale schema) senza trasportare il segreto.
36
+ AUTH_SCHEME = /(?i:Bearer|Basic|Token)/
37
+
38
+ # `token68` (RFC 7235) con padding `=` finale: la forma di una credenziale reale. Minimo 4
39
+ # caratteri — un `Basic` di credenziali brevi è corto (`dTpw` = `u:p`); a filtrare la prosa
40
+ # pensa NOT_PROSE, non la lunghezza.
41
+ CREDENTIAL_TOKEN = %r{[A-Za-z0-9\-._~+/]{4,}={0,2}}
42
+
43
+ # Guardia anti-falso-positivo per lo schema NUDO: quello che segue NON è una credenziale se ha
44
+ # la forma di una parola scritta da un umano, cioè sole lettere in UNA di tre forme —
45
+ # `minuscole`, `Capitalizzata`, `MAIUSCOLE` — e più corta di 20 caratteri. Salva "the bearer of
46
+ # bad news", "invalid bearer credentials", "Basic HTTP authentication", "Bearer Token expired".
47
+ # Tutto il resto è credenziale: entropia (cifre, `_`, `-`, `.`), maiuscole interne miste
48
+ # (`dTpw`, base64) o lunghezza ≥ 20 (nessuna parola è così lunga, mentre un token opaco di sole
49
+ # minuscole sì).
50
+ # Trade-off accettato: un CamelCase subito dopo lo schema (`Token MyAppName`) viene redatto.
51
+ PROSE_WORD = /(?:[a-z]{1,19}|[A-Z][a-z]{0,18}|[A-Z]{1,19})/
52
+ NOT_PROSE = %r{(?!\[FILTERED\])(?!#{PROSE_WORD}(?![A-Za-z0-9\-._~+/=]))}
53
+
54
+ # Valore di un header incollato nel testo: sequenza di pezzi non delimitatori, dove una stringa
55
+ # fra virgolette conta come UN pezzo. NON `\S+`, che si mangerebbe la `)` di
56
+ # "(Authorization: Bearer <token>)"; ma nemmeno una classe secca, che si fermerebbe alla prima
57
+ # virgoletta lasciando in chiaro il segreto di `Token token="<token>"` (sintassi RFC 7235 e
58
+ # `authenticate_with_http_token` di Rails) e di un frammento JSON incollato nel messaggio.
59
+ QUOTED = /"[^"\n]*"|'[^'\n]*'/
60
+ HEADER_VALUE = /(?:#{QUOTED}|[^\s,;)\]}"'<>])+/
61
+
62
+ # `Authorization:`/`authorization=` (anche `Proxy-Authorization`, anche `authorization_header`)
63
+ # seguito dal valore: qui la chiave è esplicita, quindi si redige senza euristica di prosa —
64
+ # over-redaction voluta, coerente con la DENYLIST. `[ \t]` e non `\s`: mai oltre il newline.
65
+ # Il lookahead copre lo schema OPZIONALE: senza, su un testo già redatto il motore farebbe
66
+ # backtracking sul ramo "senza schema" e redigerebbe la parola `Bearer` stessa (non idempotente).
67
+ AUTH_HEADER = /((?i:(?:proxy[_-]?)?authorization)[\w-]*["']?[ \t]*[:=][ \t]*)(?!(?:#{AUTH_SCHEME}[ \t]+)?\[FILTERED\])(#{AUTH_SCHEME}[ \t]+)?#{HEADER_VALUE}/
68
+
69
+ # Schema nudo (`Bearer <token>` senza il nome dell'header) seguito da qualcosa che ha la forma
70
+ # di una credenziale: qui la guardia di prosa serve, la parola "bearer" ricorre nei messaggi.
71
+ AUTH_CREDENTIAL = /(\b#{AUTH_SCHEME}[ \t]+)#{NOT_PROSE}#{CREDENTIAL_TOKEN}/
72
+
26
73
  STRING_LITERAL = /'(?:[^']|'')*'/
27
74
  NUMERIC_LITERAL = /\b\d+(?:\.\d+)?\b/
28
75
 
@@ -51,10 +98,16 @@ module CloseYourIt
51
98
  sql.to_s.gsub(STRING_LITERAL, "?").gsub(NUMERIC_LITERAL, "?")
52
99
  end
53
100
 
101
+ # Regola integrata (schemi di autenticazione) PRIMA dei pattern utente: le due protezioni si
102
+ # sommano, la prima non è disattivabile — privacy-by-default (CYRB-23).
54
103
  def scrub_message(message)
55
104
  return message if message.nil?
56
105
 
57
- @configuration.scrub_message_patterns.reduce(message.to_s) do |acc, pattern|
106
+ redacted = message.to_s
107
+ .gsub(AUTH_HEADER, "\\1\\2#{FILTERED}")
108
+ .gsub(AUTH_CREDENTIAL, "\\1#{FILTERED}")
109
+
110
+ @configuration.scrub_message_patterns.reduce(redacted) do |acc, pattern|
58
111
  acc.gsub(pattern, FILTERED)
59
112
  end
60
113
  end
@@ -15,6 +15,11 @@ module CloseYourIt
15
15
  # Ri-POSTiamo a Location preservando metodo + body, così l'evento non si perde in silenzio.
16
16
  MAX_REDIRECTS = 2
17
17
 
18
+ # Durata massima di UNA send_event: apertura + lettura, ripetute a ogni redirect seguito. È il
19
+ # tetto che il drain di fine vita deve poter aspettare, altrimenti abbandona un invio ancora sano
20
+ # (CYRB-24).
21
+ MAX_REQUEST_SECONDS = (OPEN_TIMEOUT + READ_TIMEOUT) * (MAX_REDIRECTS + 1)
22
+
18
23
  # Un timeout di rete (apertura o lettura) è un fallimento d'invio speciale: lo isoliamo dai non-2xx
19
24
  # e dagli altri errori di rete perché segnala tipicamente problemi di connettività (CYRB-12).
20
25
  TIMEOUT_ERRORS = [ Net::OpenTimeout, Net::ReadTimeout, Timeout::Error ].freeze
@@ -91,12 +96,28 @@ module CloseYourIt
91
96
  target.relative? ? current + target : target
92
97
  end
93
98
 
94
- # Il Bearer va ri-inviato solo se il redirect resta sullo stesso host (o la sua variante www) e
95
- # senza downgrade https→http: evita di consegnare il segreto d'ingest a un host non fidato.
99
+ # Il Bearer va ri-inviato solo se il redirect resta sull'autorità dell'endpoint configurato:
100
+ # stesso host (o la sua variante www), stessa porta effettiva e senza downgrade https→http.
101
+ # La porta fa parte del confine di fiducia (CYRB-22): sullo stesso server una porta diversa è
102
+ # un servizio distinto, che non deve ricevere il segreto d'ingest.
96
103
  def same_authority?(origin, target)
97
104
  return false if origin.scheme == "https" && target.scheme != "https"
105
+ return false unless canonical_host(origin) == canonical_host(target)
106
+
107
+ origin.port == target.port || canonical_upgrade?(origin, target)
108
+ end
109
+
110
+ # Un endpoint configurato in `http` che redirige a `https` resta fidato: la porta cambia solo
111
+ # perché cambia lo schema (80 → 443), non perché punti a un servizio su porta non standard.
112
+ def canonical_upgrade?(origin, target)
113
+ origin.scheme == "http" && target.scheme == "https" &&
114
+ origin.port == origin.default_port && target.port == target.default_port
115
+ end
98
116
 
99
- origin.host.to_s.sub(/\Awww\./, "") == target.host.to_s.sub(/\Awww\./, "")
117
+ # Una porta esplicita pari alla default (`:443` su https) equivale a ometterla: `URI#port` la
118
+ # normalizza già, qui resta solo da neutralizzare la variante www dell'host canonico.
119
+ def canonical_host(uri)
120
+ uri.host.to_s.sub(/\Awww\./, "")
100
121
  end
101
122
 
102
123
  # Estrae " R…: messaggio" dall'envelope d'errore del backend, così il dev vede il codice d'errore
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- VERSION = "0.9.3"
4
+ VERSION = "0.10.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: closeyourit-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.3
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessio Bussolari