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 +4 -4
- data/CHANGELOG.md +11 -0
- data/Gemfile.lock +1 -1
- data/lib/ree_lib/packages/ree_audit/README.md +7 -1
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/beans/audit.rb +14 -5
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/event.rb +1 -1
- data/lib/ree_lib/packages/ree_audit/spec/ree_audit/beans/audit_spec.rb +14 -0
- data/lib/ree_lib/packages/ree_roda/package/ree_roda/plugins/ree_logger.rb +62 -3
- data/lib/ree_lib/packages/ree_roda/spec/ree_roda/plugins/ree_logger_spec.rb +90 -17
- data/lib/ree_lib/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b6bab291292300f6d1067e79262021b1a1ce40474fd4d3baf2ddec2398c521f
|
|
4
|
+
data.tar.gz: fe0536bc647d3bd48cbc04197a61fc5415aa8c58fb0aa1578eb90eafaa1c424c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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 =
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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.
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
TestFilterApp.app.call(env)
|
|
103
|
+
env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/health", "QUERY_STRING" => "password=p" }
|
|
33
104
|
|
|
34
|
-
|
|
105
|
+
plain_app.app.call(env)
|
|
106
|
+
|
|
107
|
+
expect(request_line).to include("/health?password=p")
|
|
35
108
|
end
|
|
36
|
-
end
|
|
109
|
+
end
|
data/lib/ree_lib/version.rb
CHANGED