ree_lib 1.3.14 → 1.3.16

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: 2ebed252b6557db13e60268884fd6b1a0f405f256d28aaf18953764886ab6cb0
4
- data.tar.gz: e6328fda6013213c588f834b5a0782c271c76686b8303a0f458cfac5e926c80d
3
+ metadata.gz: 7b6bab291292300f6d1067e79262021b1a1ce40474fd4d3baf2ddec2398c521f
4
+ data.tar.gz: fe0536bc647d3bd48cbc04197a61fc5415aa8c58fb0aa1578eb90eafaa1c424c
5
5
  SHA512:
6
- metadata.gz: 06ad16d1ca9361397b0e324d8ab0d9a3a064337831384f45616dd500efb88367c84be252537d203e07ec08cb109e8f976980ff39ac2de315f96938a21edc697e
7
- data.tar.gz: fa3b6b910f9855585af6f0e985720db3773d7483779f65d3907badc16711e5bfdd159abcfcd8135466a5a2c57a75645da44c79ba532e0bcd9bf777d50aa7bdd5
6
+ metadata.gz: 40ec71033ebe98320f43b20cf2dd626272cc313ee3d28540fd333d8aabf403f38c87c502c174184331aa6ca7e44cc6b657e5854a740f75eff844c6e9ab2f64b7
7
+ data.tar.gz: 03433f0fd291d8709afd41856ed2141e1b418ee39d72ce19148390c86660a7a13411e99f20e0d2a98dac5d37d96fbf05fba845f0ba8b6ae7e1c395e074fc381a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.16] - 2026-10-21
4
+
5
+ - `ree_roda`: the `ree_logger` plugin applies `filter_params` to the query
6
+ string of the `Request:` line. It used to be logged verbatim, so a secret
7
+ sent in the URL reached the log even though `Params` showed it filtered.
8
+ A bracketed name (`user[password]`, also percent-encoded) is matched by
9
+ each of its segments.
10
+ - `ree_roda`: `filter_params` in the `ree_logger` plugin now walks nested
11
+ hashes and arrays; it used to check top level keys only. Names are still
12
+ matched exactly.
13
+
3
14
  ## [1.3.12] - 2026-09-02
4
15
 
5
16
  - `ree_audit`: `annotate_audit` fn — the entry point product code uses to attach
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ PATH
9
9
  PATH
10
10
  remote: .
11
11
  specs:
12
- ree_lib (1.3.14)
12
+ ree_lib (1.3.16)
13
13
  bigdecimal
14
14
  binding_of_caller
15
15
  i18n
@@ -115,7 +115,7 @@ Annotating before the check, not after, keeps a refusal in the trail as well:
115
115
  | `request_method`, `path`, `request_path` | `path` is the DSL template (stable), `request_path` is the actual path (shows the object) |
116
116
  | `params` | what the action received, after filtering |
117
117
  | `accessor` | the authenticated object — the application decides how to read it |
118
- | `status` | `:ok`, `:denied`, `:error` |
118
+ | `status` | `:ok`, `:denied`, `:not_found`, `:error` |
119
119
  | `error_type`, `error_message` | present when the call failed; message truncated to 512 chars |
120
120
  | `started_at`, `duration_ms` | timing (monotonic clock) |
121
121
  | `annotations` | whatever the application attached during the call |
@@ -124,6 +124,12 @@ Annotating before the check, not after, keeps a refusal in the trail as well:
124
124
  `:permission`. A refusal to show client data is as much an audit fact as
125
125
  showing it.
126
126
 
127
+ `:denied` and `:not_found` are split out of `:error` on purpose. A refusal by
128
+ permission and an ordinary "there was nothing to show" are both normal outcomes;
129
+ filing them next to a crash makes a compliance report say something broke when
130
+ nothing did. The split follows the domain error type, so an application gets it
131
+ without classifying anything itself.
132
+
127
133
  There is **no HTTP status** on the event, and that is not an oversight: the
128
134
  wrapper sits around the action call, while Roda sets the status afterwards (and,
129
135
  on an exception, in the application's `error` block). Observing it here is
@@ -47,7 +47,7 @@ class ReeAudit::Audit
47
47
  event.status = :ok
48
48
  result
49
49
  rescue StandardError => e
50
- event.status = denied?(e) ? :denied : :error
50
+ event.status = status_for(e)
51
51
  event.error_type = e.class.name
52
52
  event.error_message = e.message.to_s[0, MAX_ERROR_MESSAGE_LENGTH]
53
53
  raise
@@ -62,11 +62,20 @@ class ReeAudit::Audit
62
62
 
63
63
  private
64
64
 
65
- # A refusal to show client data is as much an audit fact as showing it.
66
- def denied?(e)
67
- e.is_a?(ReeErrors::Error) && e.type == :permission
65
+ # A refusal to show client data is as much an audit fact as showing it — and
66
+ # it must not be filed next to a crash. Neither must "there was nothing to
67
+ # show": a domain 404 is an ordinary outcome, and reading it as a failure in a
68
+ # compliance report tells the reader something broke when nothing did.
69
+ def status_for(e)
70
+ return :error if !e.is_a?(ReeErrors::Error)
71
+
72
+ case e.type
73
+ when :permission then :denied
74
+ when :not_found then :not_found
75
+ else :error
76
+ end
68
77
  rescue StandardError
69
- false
78
+ :error
70
79
  end
71
80
 
72
81
  # The trail must never be the reason a request fails.
@@ -4,7 +4,7 @@
4
4
  # enriched by the application through ReeAudit.annotate, and handed to a sink
5
5
  # once the call is over.
6
6
  class ReeAudit::Event
7
- STATUSES = [:ok, :denied, :error].freeze
7
+ STATUSES = [:ok, :denied, :not_found, :error].freeze
8
8
 
9
9
  attr_accessor :action_name, :package_name, :summary, :sections,
10
10
  :request_method, :path, :request_path, :params, :accessor,
@@ -3,11 +3,13 @@
3
3
  package_require('ree_audit/beans/audit')
4
4
  package_require('ree_audit/event')
5
5
  package_require('ree_errors/permission_error')
6
+ package_require('ree_errors/not_found_error')
6
7
 
7
8
  RSpec.describe :audit do
8
9
  link :audit, from: :ree_audit
9
10
  link :config, from: :ree_audit
10
11
  link :permission_error, from: :ree_errors
12
+ link :not_found_error, from: :ree_errors
11
13
  link :logger, from: :ree_logger
12
14
 
13
15
  # Captures what the application would have persisted.
@@ -89,6 +91,18 @@ RSpec.describe :audit do
89
91
  expect(written.error_message).to eq("not allowed")
90
92
  end
91
93
 
94
+ # 404 предметной области — обычный исход, а не поломка: строка «нечего
95
+ # показать» в отчёте соответствия не должна читаться как сбой.
96
+ it "records a domain not-found as its own outcome, not as a failure" do
97
+ klass = not_found_error(:missing)
98
+
99
+ expect {
100
+ audit.around(event) { raise klass.new("nothing here") }
101
+ }.to raise_error(klass)
102
+
103
+ expect(test_sink.events.first.status).to eq(:not_found)
104
+ end
105
+
92
106
  it "records an arbitrary failure as an error and lets it through" do
93
107
  expect {
94
108
  audit.around(event) { raise ArgumentError.new("boom") }
@@ -1,3 +1,4 @@
1
+ require "uri"
1
2
  package_require "ree_logger/beans/logger"
2
3
 
3
4
  class Roda
@@ -50,7 +51,7 @@ class Roda
50
51
 
51
52
  message = <<~DOC
52
53
  Request/Response details:
53
- Request: #{env["REQUEST_METHOD"]} #{env["QUERY_STRING"] && !env["QUERY_STRING"].empty? ? env["SCRIPT_NAME"].to_s + env["PATH_INFO"] + "?#{env["QUERY_STRING"]}": env["SCRIPT_NAME"].to_s + env["PATH_INFO"]} #{opts[:ree_logger_log_params] ? "\n Params: " + _ree_logger_filtered_params.inspect : ""}
54
+ Request: #{env["REQUEST_METHOD"]} #{_ree_logger_request_path(env)} #{opts[:ree_logger_log_params] ? "\n Params: " + _ree_logger_filtered_params.inspect : ""}
54
55
  Response status: #{response.status || "-"}#{(400..499).include?(response.status) ? "\n Response body: " + response.body[0].to_s : ""}
55
56
  Time Taken: #{elapsed_time}
56
57
  DOC
@@ -64,11 +65,69 @@ class Roda
64
65
 
65
66
  return params if filter_keys.empty?
66
67
 
68
+ _ree_logger_filter_value(params, filter_keys, opts[:ree_logger_filter_params_placeholder])
69
+ end
70
+
71
+ # Keys are matched by exact name at any depth: a password nested in
72
+ # `{user: {password: ...}}` is still a password. Exact match, unlike the
73
+ # substring match of ree_audit, keeps `token_id` and the like readable.
74
+ def _ree_logger_filter_value(value, filter_keys, placeholder)
75
+ case value
76
+ when Hash
77
+ value.each_with_object({}) do |(k, v), h|
78
+ h[k] = if filter_keys.include?(k.to_s)
79
+ placeholder
80
+ else
81
+ _ree_logger_filter_value(v, filter_keys, placeholder)
82
+ end
83
+ end
84
+ when Array
85
+ value.map { _ree_logger_filter_value(_1, filter_keys, placeholder) }
86
+ else
87
+ value
88
+ end
89
+ end
90
+
91
+ def _ree_logger_request_path(env)
92
+ path = env["SCRIPT_NAME"].to_s + env["PATH_INFO"].to_s
93
+ query = env["QUERY_STRING"]
94
+
95
+ return path if query.nil? || query.empty?
96
+
97
+ "#{path}?#{_ree_logger_filtered_query(query)}"
98
+ end
99
+
100
+ # The query string is logged verbatim next to the path, so without this
101
+ # a secret sent in the URL (OAuth callback code, a token from an email
102
+ # link) reached the log even though Params showed it as filtered.
103
+ def _ree_logger_filtered_query(query)
104
+ filter_keys = opts[:ree_logger_filter_params]
105
+
106
+ return query if filter_keys.empty?
107
+
67
108
  placeholder = opts[:ree_logger_filter_params_placeholder]
68
109
 
69
- params.each_with_object({}) do |(k, v), h|
70
- h[k] = filter_keys.include?(k.to_s) ? placeholder : v
110
+ query.split("&", -1).map do |pair|
111
+ key, value = pair.split("=", 2)
112
+
113
+ if value && _ree_logger_filtered_query_key?(key, filter_keys)
114
+ "#{key}=#{placeholder}"
115
+ else
116
+ pair
117
+ end
118
+ end.join("&")
119
+ end
120
+
121
+ # `user[password]=...` is the nested form of the same param, so every
122
+ # bracket segment of the name is checked.
123
+ def _ree_logger_filtered_query_key?(key, filter_keys)
124
+ name = begin
125
+ URI.decode_www_form_component(key)
126
+ rescue ArgumentError
127
+ key
71
128
  end
129
+
130
+ name.scan(/[^\[\]]+/).any? { filter_keys.include?(_1) }
72
131
  end
73
132
 
74
133
  # Create timer instance used for timing
@@ -3,34 +3,107 @@
3
3
  package_require("ree_roda/plugins/ree_logger")
4
4
 
5
5
  RSpec.describe :ree_logger do
6
- it "filters request params" do
7
- class TestFilterApp < ReeRoda::App
6
+ class TestFilterLogger
7
+ def info(msg)
8
+ logs << msg
9
+ end
10
+
11
+ def logs
12
+ @logs ||= []
13
+ end
14
+ end
15
+
16
+ let(:logger) { TestFilterLogger.new }
17
+
18
+ let(:app) {
19
+ log = logger
20
+
21
+ Class.new(ReeRoda::App) do
22
+ plugin(:ree_logger, ree_logger: log, filter_params: ["password", "token"])
23
+
8
24
  route do |r|
9
- r.get "health" do
25
+ r.is "health" do
10
26
  "success"
11
27
  end
12
-
13
- r.ree_routes
14
28
  end
15
29
  end
30
+ }
16
31
 
32
+ def request_line
33
+ logger.logs.first.lines.find { _1.include?("Request:") }
34
+ end
35
+
36
+ def params_line
37
+ logger.logs.first.lines.find { _1.include?("Params:") }
38
+ end
39
+
40
+ it "filters request params" do
17
41
  env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/health", "QUERY_STRING" => "email=test@example.com&password=password" }
18
42
 
19
- class TestFilterLogger
20
- def info(msg)
21
- @logs ||= []
22
- @logs << msg
23
- end
43
+ app.app.call(env)
44
+
45
+ expect(logger.logs.first).to match('"password" => "\[FILTERED\]"')
46
+ end
47
+
48
+ it "filters secrets in the query string of the request line" do
49
+ env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/health", "QUERY_STRING" => "email=test%40example.com&password=s3cret&token=tok-1" }
50
+
51
+ app.app.call(env)
24
52
 
25
- def logs
26
- @logs
53
+ expect(request_line).to include("/health?email=test%40example.com&password=[FILTERED]&token=[FILTERED]")
54
+ expect(logger.logs.first).not_to include("s3cret")
55
+ expect(logger.logs.first).not_to include("tok-1")
56
+ end
57
+
58
+ it "filters bracketed and encoded names in the query string" do
59
+ env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/health", "QUERY_STRING" => "user%5Bpassword%5D=s3cret&user[name]=bob&token_id=7" }
60
+
61
+ app.app.call(env)
62
+
63
+ expect(request_line).to include("user%5Bpassword%5D=[FILTERED]&user[name]=bob&token_id=7")
64
+ expect(logger.logs.first).not_to include("s3cret")
65
+ expect(params_line).to include('"token_id" => "7"')
66
+ end
67
+
68
+ it "keeps an empty query string out of the request line" do
69
+ env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/health", "QUERY_STRING" => "" }
70
+
71
+ app.app.call(env)
72
+
73
+ expect(request_line).to include("Request: GET /health \n")
74
+ end
75
+
76
+ it "filters nested params of a JSON body" do
77
+ body = '{"user":{"name":"bob","password":"s3cret"},"sessions":[{"token":"tok-1","id":1}]}'
78
+ env = {
79
+ "REQUEST_METHOD" => "POST", "PATH_INFO" => "/health",
80
+ "CONTENT_TYPE" => "application/json", "rack.input" => StringIO.new(body)
81
+ }
82
+
83
+ app.app.call(env)
84
+
85
+ expect(params_line).to include('"name" => "bob"')
86
+ expect(params_line).to include('"id" => 1')
87
+ expect(logger.logs.first).not_to include("s3cret")
88
+ expect(logger.logs.first).not_to include("tok-1")
89
+ end
90
+
91
+ it "logs the query string untouched without filter params" do
92
+ log = logger
93
+ plain_app = Class.new(ReeRoda::App) do
94
+ plugin(:ree_logger, ree_logger: log)
95
+
96
+ route do |r|
97
+ r.is "health" do
98
+ "success"
99
+ end
27
100
  end
28
101
  end
29
- logger = TestFilterLogger.new
30
102
 
31
- TestFilterApp.plugin(:ree_logger, ree_logger: logger, filter_params: ['password'])
32
- TestFilterApp.app.call(env)
103
+ env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/health", "QUERY_STRING" => "password=p" }
33
104
 
34
- expect(logger.logs.first).to match('"password" => "\[FILTERED\]"')
105
+ plain_app.app.call(env)
106
+
107
+ expect(request_line).to include("/health?password=p")
35
108
  end
36
- end
109
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReeLib
4
- VERSION = "1.3.14"
4
+ VERSION = "1.3.16"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ree_lib
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.14
4
+ version: 1.3.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ruslan Gatiyatov