pinqloq 1.1.2
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 +7 -0
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/lib/pinqloq/buffering/callback.rb +29 -0
- data/lib/pinqloq/buffering/log_buffer.rb +73 -0
- data/lib/pinqloq/buffering/log_dispatcher.rb +86 -0
- data/lib/pinqloq/buffering/queued_log.rb +7 -0
- data/lib/pinqloq/client.rb +28 -0
- data/lib/pinqloq/http/ingest_api_client.rb +157 -0
- data/lib/pinqloq/internal/throttled_warn.rb +24 -0
- data/lib/pinqloq/log_entry.rb +37 -0
- data/lib/pinqloq/log_failure_reason.rb +14 -0
- data/lib/pinqloq/log_level.rb +11 -0
- data/lib/pinqloq/log_source_type.rb +13 -0
- data/lib/pinqloq/logger.rb +40 -0
- data/lib/pinqloq/options.rb +40 -0
- data/lib/pinqloq/rack/captured_body.rb +41 -0
- data/lib/pinqloq/rack/path_match.rb +25 -0
- data/lib/pinqloq/rack/request_logging.rb +240 -0
- data/lib/pinqloq/redaction/redaction.rb +90 -0
- data/lib/pinqloq/redaction/redaction_plan.rb +120 -0
- data/lib/pinqloq/version.rb +5 -0
- data/lib/pinqloq.rb +19 -0
- metadata +132 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "log_level"
|
|
4
|
+
require_relative "log_source_type"
|
|
5
|
+
|
|
6
|
+
module Pinqloq
|
|
7
|
+
class LogEntry
|
|
8
|
+
attr_accessor :log_level, :event, :date, :app_version_name, :device_identifier,
|
|
9
|
+
:log_source_type, :collection_name, :correlation_id, :path, :metadata, :detail
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
event:,
|
|
13
|
+
log_level: LogLevel::INFORMATION,
|
|
14
|
+
date: nil,
|
|
15
|
+
app_version_name: nil,
|
|
16
|
+
device_identifier: nil,
|
|
17
|
+
log_source_type: LogSourceType::BACKEND,
|
|
18
|
+
collection_name: nil,
|
|
19
|
+
correlation_id: nil,
|
|
20
|
+
path: nil,
|
|
21
|
+
metadata: nil,
|
|
22
|
+
detail: nil
|
|
23
|
+
)
|
|
24
|
+
@event = event
|
|
25
|
+
@log_level = log_level
|
|
26
|
+
@date = date
|
|
27
|
+
@app_version_name = app_version_name
|
|
28
|
+
@device_identifier = device_identifier
|
|
29
|
+
@log_source_type = log_source_type
|
|
30
|
+
@collection_name = collection_name
|
|
31
|
+
@correlation_id = correlation_id
|
|
32
|
+
@path = path
|
|
33
|
+
@metadata = metadata
|
|
34
|
+
@detail = detail
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinqloq
|
|
4
|
+
module LogFailureReason
|
|
5
|
+
UNAUTHORIZED = :unauthorized
|
|
6
|
+
FORBIDDEN = :forbidden
|
|
7
|
+
QUEUE_FULL = :queue_full
|
|
8
|
+
HTTP_ERROR = :http_error
|
|
9
|
+
TIMEOUT = :timeout
|
|
10
|
+
NETWORK = :network
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
LogError = Struct.new(:reason, :status_code, :message, :cause, keyword_init: true)
|
|
14
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinqloq
|
|
4
|
+
class Logger
|
|
5
|
+
DEVICE_IDENTIFIER_REQUIRED_MESSAGE =
|
|
6
|
+
"Pinqloq: device_identifier is required. Set it on the entry, or configure the global " \
|
|
7
|
+
"Options#device_identifier fallback."
|
|
8
|
+
|
|
9
|
+
def initialize(buffer, dispatcher, options)
|
|
10
|
+
@buffer = buffer
|
|
11
|
+
@dispatcher = dispatcher
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def enqueue(entry, on_sent: nil, on_failed: nil)
|
|
16
|
+
ensure_device_identifier!(entry)
|
|
17
|
+
written = @buffer.enqueue(entry, on_sent: on_sent, on_failed: on_failed)
|
|
18
|
+
@dispatcher.notify_enqueued
|
|
19
|
+
written
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def enqueue_many(entries, on_sent: nil, on_failed: nil)
|
|
23
|
+
entries.each { |entry| ensure_device_identifier!(entry) }
|
|
24
|
+
written = @buffer.enqueue_many(entries, on_sent: on_sent, on_failed: on_failed)
|
|
25
|
+
@dispatcher.notify_enqueued
|
|
26
|
+
written
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def ensure_device_identifier!(entry)
|
|
32
|
+
entry_value = entry.device_identifier&.strip
|
|
33
|
+
global_value = @options.device_identifier&.strip
|
|
34
|
+
|
|
35
|
+
return if (entry_value && !entry_value.empty?) || (global_value && !global_value.empty?)
|
|
36
|
+
|
|
37
|
+
raise DEVICE_IDENTIFIER_REQUIRED_MESSAGE
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinqloq
|
|
4
|
+
INGEST_BASE_ADDRESS = "https://pinqloq-external-api.pinqponq.io"
|
|
5
|
+
|
|
6
|
+
class Options
|
|
7
|
+
DEFAULT_BULK_PATH = "api/client-logs/bulk"
|
|
8
|
+
DEFAULT_BATCH_SIZE = 200
|
|
9
|
+
DEFAULT_FLUSH_INTERVAL = 2.0
|
|
10
|
+
DEFAULT_QUEUE_CAPACITY = 10_000
|
|
11
|
+
DEFAULT_HTTP_TIMEOUT = 10.0
|
|
12
|
+
|
|
13
|
+
attr_reader :secret_key, :api_logs_collection_name, :bulk_path, :batch_size,
|
|
14
|
+
:flush_interval, :queue_capacity, :http_timeout, :app_version_name, :device_identifier
|
|
15
|
+
|
|
16
|
+
def initialize(
|
|
17
|
+
secret_key:,
|
|
18
|
+
api_logs_collection_name: nil,
|
|
19
|
+
bulk_path: DEFAULT_BULK_PATH,
|
|
20
|
+
batch_size: DEFAULT_BATCH_SIZE,
|
|
21
|
+
flush_interval: DEFAULT_FLUSH_INTERVAL,
|
|
22
|
+
queue_capacity: DEFAULT_QUEUE_CAPACITY,
|
|
23
|
+
http_timeout: DEFAULT_HTTP_TIMEOUT,
|
|
24
|
+
app_version_name: nil,
|
|
25
|
+
device_identifier: nil
|
|
26
|
+
)
|
|
27
|
+
raise ArgumentError, "Pinqloq: secret_key is required." if secret_key.nil? || secret_key.strip.empty?
|
|
28
|
+
|
|
29
|
+
@secret_key = secret_key
|
|
30
|
+
@api_logs_collection_name = api_logs_collection_name
|
|
31
|
+
@bulk_path = bulk_path
|
|
32
|
+
@batch_size = [1, batch_size].max
|
|
33
|
+
@flush_interval = [0.001, flush_interval].max
|
|
34
|
+
@queue_capacity = [1, queue_capacity].max
|
|
35
|
+
@http_timeout = [0.001, http_timeout].max
|
|
36
|
+
@app_version_name = app_version_name
|
|
37
|
+
@device_identifier = device_identifier
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinqloq
|
|
4
|
+
module Rack
|
|
5
|
+
class CapturedBody
|
|
6
|
+
def initialize(body, max_bytes)
|
|
7
|
+
@body = body
|
|
8
|
+
@max_bytes = max_bytes
|
|
9
|
+
@chunks = []
|
|
10
|
+
@captured_bytes = 0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def each(&block)
|
|
14
|
+
@body.each do |chunk|
|
|
15
|
+
capture(chunk)
|
|
16
|
+
block.call(chunk)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def close
|
|
21
|
+
@body.close if @body.respond_to?(:close)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def captured_string
|
|
25
|
+
@chunks.join.dup.force_encoding("UTF-8")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def capture(chunk)
|
|
31
|
+
return if @captured_bytes >= @max_bytes
|
|
32
|
+
|
|
33
|
+
remaining = @max_bytes - @captured_bytes
|
|
34
|
+
slice = chunk.bytesize > remaining ? chunk.byteslice(0, remaining) : chunk
|
|
35
|
+
|
|
36
|
+
@chunks << slice
|
|
37
|
+
@captured_bytes += slice.bytesize
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinqloq
|
|
4
|
+
module Rack
|
|
5
|
+
module PathMatch
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def matches_any_prefix?(path, prefixes)
|
|
9
|
+
return false if prefixes.nil? || prefixes.empty?
|
|
10
|
+
|
|
11
|
+
lower_path = path.downcase
|
|
12
|
+
prefixes.any? { |prefix| matches_segment_prefix?(lower_path, prefix.downcase) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def matches_segment_prefix?(lower_path, prefix)
|
|
16
|
+
normalized = prefix.start_with?("/") ? prefix : "/#{prefix}"
|
|
17
|
+
trimmed = (normalized.length > 1 && normalized.end_with?("/")) ? normalized[0..-2] : normalized
|
|
18
|
+
|
|
19
|
+
return false unless lower_path.start_with?(trimmed)
|
|
20
|
+
|
|
21
|
+
lower_path.length == trimmed.length || lower_path[trimmed.length] == "/"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require_relative "path_match"
|
|
7
|
+
require_relative "captured_body"
|
|
8
|
+
require_relative "../redaction/redaction"
|
|
9
|
+
require_relative "../log_level"
|
|
10
|
+
require_relative "../log_source_type"
|
|
11
|
+
require_relative "../internal/throttled_warn"
|
|
12
|
+
|
|
13
|
+
module Pinqloq
|
|
14
|
+
module Rack
|
|
15
|
+
class RequestLogging
|
|
16
|
+
MAX_BODY_CHARACTERS = 32 * 1024
|
|
17
|
+
MAX_BODY_BYTES = 4 * MAX_BODY_CHARACTERS
|
|
18
|
+
|
|
19
|
+
DEVICE_IDENTIFIER_HEADER_NAME = "device-identifier"
|
|
20
|
+
CORRELATION_ID_HEADER_NAME = "correlation-id"
|
|
21
|
+
|
|
22
|
+
DEVICE_IDENTIFIER_REQUIRED_MESSAGE =
|
|
23
|
+
"Pinqloq: the required device_identifier could not be resolved. Send the " \
|
|
24
|
+
"'device-identifier' request header, or configure resolve_device_identifier, or set " \
|
|
25
|
+
"Options#device_identifier."
|
|
26
|
+
|
|
27
|
+
SERVER_ERROR_STATUS_THRESHOLD = 500
|
|
28
|
+
CLIENT_ERROR_STATUS_THRESHOLD = 400
|
|
29
|
+
|
|
30
|
+
SELECTOR_WARNING_THROTTLE_SECONDS = 60
|
|
31
|
+
|
|
32
|
+
def initialize(
|
|
33
|
+
app,
|
|
34
|
+
logger:,
|
|
35
|
+
pinqloq_options:,
|
|
36
|
+
exclude_paths: [],
|
|
37
|
+
resolve_device_identifier: nil,
|
|
38
|
+
resolve_app_version_name: nil,
|
|
39
|
+
metadata: {},
|
|
40
|
+
detail: {},
|
|
41
|
+
redact_fields: [],
|
|
42
|
+
redact_paths: []
|
|
43
|
+
)
|
|
44
|
+
@app = app
|
|
45
|
+
@logger = logger
|
|
46
|
+
@pinqloq_options = pinqloq_options
|
|
47
|
+
@exclude_paths = exclude_paths
|
|
48
|
+
@resolve_device_identifier = resolve_device_identifier
|
|
49
|
+
@resolve_app_version_name = resolve_app_version_name
|
|
50
|
+
@metadata_enrichers = metadata
|
|
51
|
+
@detail_enrichers = detail
|
|
52
|
+
@redact_fields = redact_fields
|
|
53
|
+
@redact_paths = redact_paths
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def call(env)
|
|
57
|
+
request = ::Rack::Request.new(env)
|
|
58
|
+
path = request.path
|
|
59
|
+
|
|
60
|
+
return @app.call(env) if PathMatch.matches_any_prefix?(path, @exclude_paths)
|
|
61
|
+
|
|
62
|
+
device_identifier = resolve_device_identifier(request)
|
|
63
|
+
if device_identifier.nil? || device_identifier.strip.empty?
|
|
64
|
+
return [400, { "Content-Type" => "text/plain" }, [DEVICE_IDENTIFIER_REQUIRED_MESSAGE]]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
redact_plan = build_redact_plan(path)
|
|
68
|
+
started_at = monotonic_now
|
|
69
|
+
|
|
70
|
+
request_headers = truncate(Redaction.serialize_headers(request_header_hash(env), redact_plan))
|
|
71
|
+
input_json = truncate(Redaction.apply_body_redaction(read_request_body(request), redact_plan))
|
|
72
|
+
correlation_id = resolve_correlation_id(request)
|
|
73
|
+
app_version_name = resolve_selector(@resolve_app_version_name, request, "resolve_app_version_name")
|
|
74
|
+
|
|
75
|
+
status, headers, body = @app.call(env)
|
|
76
|
+
|
|
77
|
+
captured_body = CapturedBody.new(body, MAX_BODY_BYTES)
|
|
78
|
+
wrapped_body = ::Rack::BodyProxy.new(captured_body) do
|
|
79
|
+
finish_log(
|
|
80
|
+
method: request.request_method,
|
|
81
|
+
path: path,
|
|
82
|
+
status: status,
|
|
83
|
+
headers: headers,
|
|
84
|
+
elapsed_ms: ((monotonic_now - started_at) * 1000).round,
|
|
85
|
+
device_identifier: device_identifier,
|
|
86
|
+
app_version_name: app_version_name,
|
|
87
|
+
correlation_id: correlation_id,
|
|
88
|
+
request_headers: request_headers,
|
|
89
|
+
input_json: input_json,
|
|
90
|
+
output_json: truncate(Redaction.apply_body_redaction(captured_body.captured_string, redact_plan)),
|
|
91
|
+
request: request,
|
|
92
|
+
redact_plan: redact_plan
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
[status, headers, wrapped_body]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def build_redact_plan(path)
|
|
102
|
+
if !@redact_paths.empty? && PathMatch.matches_any_prefix?(path, @redact_paths)
|
|
103
|
+
Redaction::Plan::ALL
|
|
104
|
+
else
|
|
105
|
+
Redaction::Plan.new(redact_all: false, declared_names: @redact_fields)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def finish_log(method:, path:, status:, headers:, elapsed_ms:, device_identifier:, app_version_name:,
|
|
110
|
+
correlation_id:, request_headers:, input_json:, output_json:, request:, redact_plan:)
|
|
111
|
+
response_headers = truncate(Redaction.serialize_headers(headers, redact_plan))
|
|
112
|
+
|
|
113
|
+
metadata = {}
|
|
114
|
+
metadata["event"] = "#{method} #{path}".strip
|
|
115
|
+
apply_enrichers(metadata, @metadata_enrichers, request, status, headers)
|
|
116
|
+
resolved_event_name = metadata.delete("event")
|
|
117
|
+
|
|
118
|
+
metadata["method"] = method
|
|
119
|
+
metadata["statusCode"] = status.to_s
|
|
120
|
+
metadata["durationMs"] = elapsed_ms.to_s
|
|
121
|
+
metadata["RequestMethod"] = method
|
|
122
|
+
metadata["ResponseCode"] = status.to_s
|
|
123
|
+
|
|
124
|
+
detail = {}
|
|
125
|
+
apply_enrichers(detail, @detail_enrichers, request, status, headers)
|
|
126
|
+
detail["InputJson"] = input_json
|
|
127
|
+
detail["OutputJson"] = output_json
|
|
128
|
+
detail["RequestHeaders"] = request_headers
|
|
129
|
+
detail["ResponseHeaders"] = response_headers
|
|
130
|
+
|
|
131
|
+
@logger.enqueue(
|
|
132
|
+
Pinqloq::LogEntry.new(
|
|
133
|
+
event: resolved_event_name,
|
|
134
|
+
log_level: resolve_log_level(status),
|
|
135
|
+
device_identifier: device_identifier,
|
|
136
|
+
app_version_name: (app_version_name.nil? || app_version_name.empty?) ? nil : app_version_name,
|
|
137
|
+
log_source_type: LogSourceType::BACKEND,
|
|
138
|
+
correlation_id: correlation_id,
|
|
139
|
+
path: path,
|
|
140
|
+
metadata: metadata,
|
|
141
|
+
detail: detail
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resolve_log_level(status)
|
|
147
|
+
return LogLevel::ERROR if status >= SERVER_ERROR_STATUS_THRESHOLD
|
|
148
|
+
return LogLevel::WARNING if status >= CLIENT_ERROR_STATUS_THRESHOLD
|
|
149
|
+
|
|
150
|
+
LogLevel::INFORMATION
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def resolve_device_identifier(request)
|
|
154
|
+
overridden = resolve_selector(@resolve_device_identifier, request, "resolve_device_identifier")
|
|
155
|
+
return overridden unless overridden.nil? || overridden.strip.empty?
|
|
156
|
+
|
|
157
|
+
header = request.get_header(rack_header_key(DEVICE_IDENTIFIER_HEADER_NAME))
|
|
158
|
+
return header unless header.nil? || header.strip.empty?
|
|
159
|
+
|
|
160
|
+
@pinqloq_options.device_identifier
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def resolve_correlation_id(request)
|
|
164
|
+
header = request.get_header(rack_header_key(CORRELATION_ID_HEADER_NAME))
|
|
165
|
+
(header && !header.strip.empty?) ? header : SecureRandom.uuid
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def resolve_selector(selector, request, throttle_key)
|
|
169
|
+
return nil unless selector
|
|
170
|
+
|
|
171
|
+
selector.call(request)
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
Internal::ThrottledWarn.warn_throttled(
|
|
174
|
+
throttle_key,
|
|
175
|
+
SELECTOR_WARNING_THROTTLE_SECONDS,
|
|
176
|
+
"Pinqloq: #{throttle_key} raised an exception; ignored. #{e.class}: #{e.message}"
|
|
177
|
+
)
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def apply_enrichers(target, enrichers, request, status, headers)
|
|
182
|
+
enrichers.each do |key, selector|
|
|
183
|
+
value =
|
|
184
|
+
begin
|
|
185
|
+
selector.call(request, status, headers)
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
Internal::ThrottledWarn.warn_throttled(
|
|
188
|
+
"enricher:#{key}",
|
|
189
|
+
SELECTOR_WARNING_THROTTLE_SECONDS,
|
|
190
|
+
"Pinqloq: the '#{key}' enricher raised an exception; ignored. #{e.class}: #{e.message}"
|
|
191
|
+
)
|
|
192
|
+
next
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
target[key.to_s] = value.to_s unless value.nil?
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def request_header_hash(env)
|
|
200
|
+
headers = env.each_with_object({}) do |(key, value), acc|
|
|
201
|
+
next unless key.start_with?("HTTP_")
|
|
202
|
+
|
|
203
|
+
header_name = key.sub(/\AHTTP_/, "").tr("_", "-")
|
|
204
|
+
acc[header_name] = value
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
headers["Content-Type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
|
|
208
|
+
headers["Content-Length"] = env["CONTENT_LENGTH"] if env["CONTENT_LENGTH"]
|
|
209
|
+
headers
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def rack_header_key(header_name)
|
|
213
|
+
"HTTP_#{header_name.upcase.tr('-', '_')}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def read_request_body(request)
|
|
217
|
+
input = request.get_header("rack.input")
|
|
218
|
+
return "" unless input
|
|
219
|
+
|
|
220
|
+
if input.respond_to?(:rewind)
|
|
221
|
+
body = input.read(MAX_BODY_BYTES) || ""
|
|
222
|
+
input.rewind
|
|
223
|
+
return body.force_encoding("UTF-8")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
full_body = (input.read || "").force_encoding("UTF-8")
|
|
227
|
+
request.set_header("rack.input", StringIO.new(full_body))
|
|
228
|
+
full_body[0, MAX_BODY_BYTES] || ""
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def truncate(value)
|
|
232
|
+
value.length <= MAX_BODY_CHARACTERS ? value : value[0, MAX_BODY_CHARACTERS]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def monotonic_now
|
|
236
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "redaction_plan"
|
|
5
|
+
require_relative "../internal/throttled_warn"
|
|
6
|
+
|
|
7
|
+
module Pinqloq
|
|
8
|
+
module Redaction
|
|
9
|
+
REDACTED_VALUE = "*****REDACTED*****"
|
|
10
|
+
|
|
11
|
+
UNPARSEABLE_SENSITIVE_BODY_VALUE =
|
|
12
|
+
"*****REDACTED: body carries a credential field and is not parseable JSON " \
|
|
13
|
+
"(non-JSON content type, or longer than the capture limit)*****"
|
|
14
|
+
|
|
15
|
+
MALFORMED_BODY_WARNING_THROTTLE_SECONDS = 60
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def redact_properties(value, plan)
|
|
19
|
+
case value
|
|
20
|
+
when Array
|
|
21
|
+
value.map { |item| redact_properties(item, plan) }
|
|
22
|
+
when Hash
|
|
23
|
+
value.each_with_object({}) do |(key, item), result|
|
|
24
|
+
result[key] = plan.should_redact?(key.to_s) ? REDACTED_VALUE : redact_properties(item, plan)
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def redact_fully(value)
|
|
32
|
+
case value
|
|
33
|
+
when Array
|
|
34
|
+
value.map { |item| redact_fully(item) }
|
|
35
|
+
when Hash
|
|
36
|
+
value.each_with_object({}) { |(key, item), result| result[key] = redact_fully(item) }
|
|
37
|
+
else
|
|
38
|
+
REDACTED_VALUE
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def redact_json_properties(body, plan, sensitive)
|
|
43
|
+
return body if body.strip.empty?
|
|
44
|
+
|
|
45
|
+
JSON.generate(redact_properties(JSON.parse(body), plan))
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
Internal::ThrottledWarn.warn_throttled(
|
|
48
|
+
:redact_json_properties,
|
|
49
|
+
MALFORMED_BODY_WARNING_THROTTLE_SECONDS,
|
|
50
|
+
"Pinqloq: a captured body could not be parsed as JSON; falling back to whole-body handling. #{e.class}: #{e.message}"
|
|
51
|
+
)
|
|
52
|
+
sensitive ? UNPARSEABLE_SENSITIVE_BODY_VALUE : body
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def redact_json_fully(body)
|
|
56
|
+
return body if body.strip.empty?
|
|
57
|
+
|
|
58
|
+
JSON.generate(redact_fully(JSON.parse(body)))
|
|
59
|
+
rescue JSON::ParserError => e
|
|
60
|
+
Internal::ThrottledWarn.warn_throttled(
|
|
61
|
+
:redact_json_fully,
|
|
62
|
+
MALFORMED_BODY_WARNING_THROTTLE_SECONDS,
|
|
63
|
+
"Pinqloq: a captured body under a redact_all plan could not be parsed as JSON; masking it wholesale. #{e.class}: #{e.message}"
|
|
64
|
+
)
|
|
65
|
+
REDACTED_VALUE
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def apply_body_redaction(body, plan)
|
|
69
|
+
return redact_json_fully(body) if plan.redact_all
|
|
70
|
+
|
|
71
|
+
mentions_credential = Plan.contains_always_redacted_name?(body) || plan.contains_declared_name?(body)
|
|
72
|
+
|
|
73
|
+
return body if !plan.has_declared_redactions? && !mentions_credential
|
|
74
|
+
|
|
75
|
+
redact_json_properties(body, plan, mentions_credential)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def serialize_headers(headers, plan)
|
|
79
|
+
result = headers.each_with_object({}) do |(key, value), acc|
|
|
80
|
+
next if value.nil?
|
|
81
|
+
|
|
82
|
+
string_value = value.is_a?(Array) ? value.join(", ") : value.to_s
|
|
83
|
+
acc[key.to_s] = plan.should_redact?(key.to_s) ? REDACTED_VALUE : string_value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
JSON.generate(result)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Pinqloq
|
|
6
|
+
module Redaction
|
|
7
|
+
ALWAYS_REDACTED_NAMES = Set.new(
|
|
8
|
+
[
|
|
9
|
+
"authorization",
|
|
10
|
+
"proxy-authorization",
|
|
11
|
+
"cookie",
|
|
12
|
+
"set-cookie",
|
|
13
|
+
"x-api-key",
|
|
14
|
+
"x-secret-key",
|
|
15
|
+
"x-auth-token",
|
|
16
|
+
"x-access-token",
|
|
17
|
+
"x-csrf-token",
|
|
18
|
+
"x-xsrf-token",
|
|
19
|
+
"secret_key",
|
|
20
|
+
"password",
|
|
21
|
+
"newpassword",
|
|
22
|
+
"oldpassword",
|
|
23
|
+
"currentpassword",
|
|
24
|
+
"passwordconfirmation",
|
|
25
|
+
"confirmpassword",
|
|
26
|
+
"secret",
|
|
27
|
+
"secretkey",
|
|
28
|
+
"clientsecret",
|
|
29
|
+
"apikey",
|
|
30
|
+
"accesstoken",
|
|
31
|
+
"refreshtoken",
|
|
32
|
+
"idtoken",
|
|
33
|
+
"token",
|
|
34
|
+
"otp",
|
|
35
|
+
"otpcode",
|
|
36
|
+
"verificationcode",
|
|
37
|
+
"pin",
|
|
38
|
+
"privatekey",
|
|
39
|
+
"cardnumber",
|
|
40
|
+
"cvv",
|
|
41
|
+
"cvc",
|
|
42
|
+
"securitycode",
|
|
43
|
+
"iban",
|
|
44
|
+
"ssn"
|
|
45
|
+
]
|
|
46
|
+
).freeze
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
def bounded_name?(body, index, length)
|
|
51
|
+
end_index = index + length
|
|
52
|
+
|
|
53
|
+
before = index.positive? ? body[index - 1] : nil
|
|
54
|
+
at_start = body[index]
|
|
55
|
+
after = end_index < body.length ? body[end_index] : nil
|
|
56
|
+
|
|
57
|
+
start_bounded = index.zero? || !letter_or_digit?(before) || (upper?(at_start) && !upper?(before))
|
|
58
|
+
end_bounded = end_index == body.length || !letter_or_digit?(after) || upper?(after)
|
|
59
|
+
|
|
60
|
+
start_bounded && end_bounded
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def letter_or_digit?(char)
|
|
64
|
+
!char.nil? && char =~ /[A-Za-z0-9]/ ? true : false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def upper?(char)
|
|
68
|
+
!char.nil? && char =~ /[A-Z]/ ? true : false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def contains_whole_name?(body, names)
|
|
72
|
+
lower_body = body.downcase
|
|
73
|
+
|
|
74
|
+
names.each do |name|
|
|
75
|
+
next if name.empty?
|
|
76
|
+
|
|
77
|
+
search_from = 0
|
|
78
|
+
while search_from <= lower_body.length - name.length
|
|
79
|
+
index = lower_body.index(name, search_from)
|
|
80
|
+
break if index.nil?
|
|
81
|
+
|
|
82
|
+
return true if bounded_name?(body, index, name.length)
|
|
83
|
+
|
|
84
|
+
search_from = index + 1
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Plan
|
|
92
|
+
attr_reader :redact_all
|
|
93
|
+
|
|
94
|
+
def initialize(redact_all: false, declared_names: [])
|
|
95
|
+
@redact_all = redact_all
|
|
96
|
+
@declared_names = Set.new(declared_names.map(&:downcase))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def has_declared_redactions?
|
|
100
|
+
@redact_all || !@declared_names.empty?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def should_redact?(property_or_header_name)
|
|
104
|
+
lower = property_or_header_name.downcase
|
|
105
|
+
@redact_all || ALWAYS_REDACTED_NAMES.include?(lower) || @declared_names.include?(lower)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def contains_declared_name?(body)
|
|
109
|
+
Redaction.contains_whole_name?(body, @declared_names)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.contains_always_redacted_name?(body)
|
|
113
|
+
Redaction.contains_whole_name?(body, ALWAYS_REDACTED_NAMES)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
NONE = new(redact_all: false, declared_names: [])
|
|
117
|
+
ALL = new(redact_all: true, declared_names: [])
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|