Skip to content

Commit fb6e123

Browse files
committed
Fix logging fallback sanitization
1 parent 996fa53 commit fb6e123

3 files changed

Lines changed: 61 additions & 15 deletions

File tree

app/web/security/security_logger.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ def log_event(event_type, data, severity: :warn)
148148
# @param event_type [String] type of security event
149149
# @param data [Hash] event data
150150
def handle_logging_error(error, event_type, data)
151+
sanitized_data = LogSanitizer.sanitize_details(data)
151152
Kernel.warn("Structured logging fallback: #{error.class}: #{error.message}")
152-
Kernel.warn("component=security_logger security_event=#{event_type} details=#{data}")
153+
Kernel.warn("component=security_logger security_event=#{event_type} details=#{sanitized_data}")
153154
end
154155
end
155156
end

app/web/telemetry/app_logger.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,27 @@ def base_payload(severity, datetime)
5252
# @param message [Object]
5353
# @return [Hash{Symbol=>Object}]
5454
def normalize_message(message)
55-
parsed_json(message) || parse_logfmt(message.to_s) || { message: message.to_s }
55+
message_string = message.to_s
56+
return parsed_json(message_string) if json_like?(message_string)
57+
58+
parse_logfmt(message_string) || { message: message_string }
5659
end
5760

58-
# @param message [Object]
61+
# @param message [String]
5962
# @return [Hash{Symbol=>Object}, nil]
6063
def parsed_json(message)
61-
JSON.parse(message.to_s, symbolize_names: true)
64+
JSON.parse(message, symbolize_names: true)
6265
rescue JSON::ParserError, TypeError
6366
nil
6467
end
6568

69+
# @param message [String]
70+
# @return [Boolean]
71+
def json_like?(message)
72+
stripped = message.lstrip
73+
stripped.start_with?('{', '[')
74+
end
75+
6676
# @param message [String]
6777
# @return [Hash{Symbol=>Object}, nil]
6878
def parse_logfmt(message)

spec/html2rss/web/log_sanitizer_spec.rb

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212

1313
RSpec.describe Html2rss::Web::LogSanitizer do
1414
let(:io) { StringIO.new }
15-
let(:logger) { Logger.new(io).tap { |log| log.formatter = Html2rss::Web::AppLogger.send(:method, :format_entry) } }
16-
let(:sanitized_url) do
15+
let(:test_logger) { Logger.new(io).tap { |log| log.formatter = Html2rss::Web::AppLogger.send(:method, :format_entry) } }
16+
let(:expected_news_url) do
1717
{
1818
host: 'news.ycombinator.com',
1919
scheme: 'https',
@@ -36,7 +36,7 @@
3636
Html2rss::Web::RequestContext.set!(context)
3737
Html2rss::Web::AppLogger.reset_logger!
3838
Html2rss::Web::SecurityLogger.reset_logger!
39-
allow(Html2rss::Web::AppLogger).to receive(:logger).and_return(logger)
39+
allow(Logger).to receive(:new).and_return(test_logger)
4040
end
4141

4242
after do
@@ -52,7 +52,7 @@
5252
end
5353

5454
it 'replaces logged urls with hashed host metadata' do
55-
expect(described_class.sanitize_details(url: 'https://news.ycombinator.com')).to eq(url: sanitized_url)
55+
expect(described_class.sanitize_details(url: 'https://news.ycombinator.com')).to eq(url: expected_news_url)
5656
end
5757

5858
it 'falls back to a hash for malformed urls' do
@@ -76,13 +76,22 @@
7676
expect(payload.slice(:path, :details)).to eq(
7777
path: '/api/v1/feeds/[REDACTED]',
7878
details: {
79-
url: sanitized_url,
79+
url: expected_news_url,
8080
token_hash: Digest::SHA256.hexdigest('very-secret-token')[0..7],
8181
success: true
8282
}
8383
)
8484
end
8585

86+
it 'sanitizes security logger fallback output when structured logging fails' do
87+
allow(Html2rss::Web::LogEvent).to receive(:emit).and_raise(JSON::GeneratorError, 'boom')
88+
allow(Kernel).to receive(:warn)
89+
emit_failing_token_usage_log
90+
91+
expect(Kernel).to have_received(:warn).with('Structured logging fallback: JSON::GeneratorError: boom')
92+
expect(fallback_warning_line).to eq(expected_fallback_warning_line)
93+
end
94+
8695
it 'sanitizes observability details' do
8796
Html2rss::Web::Observability.emit(
8897
event_name: 'feed.render',
@@ -93,11 +102,11 @@
93102
lines = io.string.lines.map { |line| JSON.parse(line, symbolize_names: true) }
94103
observability_payload = lines.first
95104

96-
expect(observability_payload.dig(:details, :url)).to eq(sanitized_url)
105+
expect(observability_payload.dig(:details, :url)).to eq(expected_news_url)
97106
end
98107

99108
it 'formats rack-timeout logfmt as json' do
100-
logger.info('source=rack-timeout id=req-123 timeout=15000ms state=completed')
109+
Html2rss::Web::AppLogger.logger.info('source=rack-timeout id=req-123 timeout=15000ms state=completed')
101110

102111
payload = JSON.parse(io.string.lines.last, symbolize_names: true)
103112
expect(payload).to include(
@@ -122,20 +131,20 @@ def nested_url_payload
122131
# @return [Hash{Symbol=>Object}]
123132
def expected_nested_url_payload
124133
{
125-
url: sanitized_url('news.ycombinator.com', 'https://news.ycombinator.com'),
134+
url: expected_sanitized_url('news.ycombinator.com', 'https://news.ycombinator.com'),
126135
related_urls: [
127-
sanitized_url('example.com', 'https://example.com/feed.xml')
136+
expected_sanitized_url('example.com', 'https://example.com/feed.xml')
128137
],
129138
details: {
130-
url: sanitized_url('lobste.rs', 'https://lobste.rs/s/test')
139+
url: expected_sanitized_url('lobste.rs', 'https://lobste.rs/s/test')
131140
}
132141
}
133142
end
134143

135144
# @param host [String]
136145
# @param url [String]
137146
# @return [Hash{Symbol=>String}]
138-
def sanitized_url(host, url)
147+
def expected_sanitized_url(host, url)
139148
{ host:, scheme: 'https', hash: url_hash(url) }
140149
end
141150

@@ -144,4 +153,30 @@ def sanitized_url(host, url)
144153
def url_hash(url)
145154
Digest::SHA256.hexdigest(url)[0..11]
146155
end
156+
157+
# @return [void]
158+
def emit_failing_token_usage_log
159+
Html2rss::Web::SecurityLogger.log_token_usage(
160+
'very-secret-token',
161+
'https://news.ycombinator.com/private/feed',
162+
true
163+
)
164+
end
165+
166+
# @return [String]
167+
def fallback_warning_line
168+
warning_messages = RSpec::Mocks.space.proxy_for(Kernel).messages_arg_list.map(&:first)
169+
warning_messages.find { |message| message.include?('component=security_logger') }
170+
end
171+
172+
# @return [Hash{Symbol=>String}]
173+
def sanitized_fallback_url
174+
expected_sanitized_url('news.ycombinator.com', 'https://news.ycombinator.com/private/feed')
175+
end
176+
177+
# @return [String]
178+
def expected_fallback_warning_line
179+
'component=security_logger security_event=token_usage ' \
180+
"details={success: true, url: #{sanitized_fallback_url.inspect}, token_hash: \"01cadf39\"}"
181+
end
147182
end

0 commit comments

Comments
 (0)