Module: Dinie::Internal::LogRedaction

Defined in:
lib/dinie/runtime/logger.rb

Overview

Pure, stateless redaction + truncation helpers shared by RuntimeLogger. A financial backend carries PII and credentials in headers and bodies, so every value the logger emits passes through here first (architecture §9, RB12). No I/O, no state — trivially testable.

The body-field redaction list is **derived from** Model::REDACTED_ATTRIBUTES (story 002) so the logger and the model ‘#inspect` can never drift apart: one source of truth for “what counts as PII” (the story’s stated risk — divergence here is a leak).

Constant Summary collapse

REDACTED =

Mask substituted for any redacted header value or body field.

"[REDACTED]"
MAX_BODY_BYTES =

Bodies whose serialized size reaches this many bytes are truncated (architecture §9).

2048
REDACTED_HEADERS =

Header names (lowercased) whose values carry credentials/signatures → masked. Covers the ‘Authorization: Basic` of the token POST (client secret), the webhook HMAC, the per-call client-secret header, and any forward-proxy auth.

Set.new(
  %w[authorization webhook-signature x-dinie-client-secret proxy-authorization]
).freeze
REDACTED_BODY_FIELDS =

Body field names (lowercased) carrying PII/secrets → masked recursively. Mirrors the model’s ‘#inspect` redaction set verbatim (the canonical list, story 002).

Set.new(Model::REDACTED_ATTRIBUTES.map(&:to_s)).freeze

Class Method Summary collapse

Class Method Details

.clip_to_bytes(text, max_bytes) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Take whole characters until the next one would exceed ‘max_bytes`.



109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/dinie/runtime/logger.rb', line 109

def clip_to_bytes(text, max_bytes)
  bytes = 0
  clipped = +""
  text.each_char do |char|
    char_bytes = char.bytesize
    break if bytes + char_bytes > max_bytes

    bytes += char_bytes
    clipped << char
  end
  clipped
end

.format_body(body) ⇒ String?

Render a body for logging: redact PII, serialize to JSON, then truncate. A String body is JSON-parsed first (so its fields are redacted) and logged verbatim when it is not JSON. ‘nil`/empty bodies return `nil` (the caller drops the key).

Parameters:

  • body (Object, nil)

    the raw request/response body

Returns:

  • (String, nil)


80
81
82
83
84
# File 'lib/dinie/runtime/logger.rb', line 80

def format_body(body)
  return nil if body.nil? || (body.is_a?(String) && body.empty?)

  truncate_body(serialize_for_log(body))
end

.parse_json(text) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



123
124
125
126
127
# File 'lib/dinie/runtime/logger.rb', line 123

def parse_json(text)
  JSON.parse(text)
rescue JSON::ParserError
  NOT_JSON
end

.redact_body(value) ⇒ Object

Deep-copy ‘value`, masking any Hash key (case-insensitive) that names a PII/secret field. Arrays and nested objects are walked; scalars pass through untouched.

Parameters:

  • value (Object)

    a parsed body (Hash/Array/scalar)

Returns:

  • (Object)

    the redacted copy



59
60
61
62
63
64
65
# File 'lib/dinie/runtime/logger.rb', line 59

def redact_body(value)
  case value
  when Array then value.map { |item| redact_body(item) }
  when Hash then redact_hash(value)
  else value
  end
end

.redact_hash(hash) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



68
69
70
71
72
# File 'lib/dinie/runtime/logger.rb', line 68

def redact_hash(hash)
  hash.each_with_object({}) do |(key, value), out|
    out[key] = REDACTED_BODY_FIELDS.include?(key.to_s.downcase) ? REDACTED : redact_body(value)
  end
end

.redact_headers(headers) ⇒ Hash{String => Object}

Replace the value of any sensitive header with the mask (case-insensitive). Accepts a Hash or ‘Faraday::Utils::Headers`; returns a plain Hash with the original key casing preserved.

Parameters:

  • headers (#each, nil)

    request/response headers

Returns:

  • (Hash{String => Object})


46
47
48
49
50
51
52
# File 'lib/dinie/runtime/logger.rb', line 46

def redact_headers(headers)
  return {} if headers.nil?

  headers.each_with_object({}) do |(key, value), out|
    out[key.to_s] = REDACTED_HEADERS.include?(key.to_s.downcase) ? REDACTED : value
  end
end

.safe_generate(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

‘JSON.generate` that never raises (a log line must not blow up the request).



131
132
133
134
135
# File 'lib/dinie/runtime/logger.rb', line 131

def safe_generate(value)
  JSON.generate(value)
rescue StandardError
  "[unserializable]"
end

.serialize_for_log(body) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



87
88
89
90
91
92
# File 'lib/dinie/runtime/logger.rb', line 87

def serialize_for_log(body)
  return safe_generate(redact_body(body)) unless body.is_a?(String)

  parsed = parse_json(body)
  parsed.equal?(NOT_JSON) ? body : safe_generate(redact_body(parsed))
end

.truncate_body(text) ⇒ String

Truncate to MAX_BODY_BYTES on a codepoint boundary, appending ‘…[truncated, full_size=NNN]` (NNN = full UTF-8 byte size). Returns the input unchanged when under the threshold (so a binary/multipart blob never floods the log — story risk note).

Parameters:

  • text (String)

Returns:

  • (String)


100
101
102
103
104
105
# File 'lib/dinie/runtime/logger.rb', line 100

def truncate_body(text)
  full_size = text.bytesize
  return text if full_size < MAX_BODY_BYTES

  "#{clip_to_bytes(text, MAX_BODY_BYTES)}…[truncated, full_size=#{full_size}]"
end