Skip to content

Commit ee7df73

Browse files
authored
feat: redact sensitive feed data in structured logs (#903)
## Summary - redact feed tokens from request-scoped logging paths - replace logged source URLs with hashed host metadata - consolidate security and observability emission through a shared structured logger - route rack-timeout logging through the same JSON logger ## Verification - docker compose -f .devcontainer/docker-compose.yml up -d - docker exec devcontainer-app-1 bash -lc 'cd /workspace && make setup && make ready' ## Notes - make ready passed with the new redacted log shape visible in the exercised request logs during RSpec ---
1 parent df4b0a9 commit ee7df73

12 files changed

Lines changed: 540 additions & 73 deletions

app/web/boot/setup.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class << self
1313
def call!
1414
validate_environment!
1515
configure_request_service!
16+
configure_runtime_logging!
1617
end
1718

1819
private
@@ -28,6 +29,13 @@ def validate_environment!
2829
def configure_request_service!
2930
nil
3031
end
32+
33+
# @return [void]
34+
def configure_runtime_logging!
35+
return unless defined?(Rack::Timeout::Logger)
36+
37+
Rack::Timeout::Logger.logger = AppLogger.logger
38+
end
3139
end
3240
end
3341
end

app/web/config/environment_validator.rb

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,17 @@ def auto_source_enabled?
4949

5050
def set_development_key
5151
ENV['HTML2RSS_SECRET_KEY'] = 'development-default-key-not-for-production'
52-
puts '⚠️ WARNING: Using default secret key for development/testing only!'
53-
puts ' Set HTML2RSS_SECRET_KEY environment variable for production use.'
52+
log_development_default_secret_key_warning
53+
warn_lines(
54+
'WARNING: Using default secret key for development/testing only!',
55+
'Set HTML2RSS_SECRET_KEY environment variable for production use.'
56+
)
57+
nil
5458
end
5559

5660
def show_production_error
57-
puts production_error_message
61+
SecurityLogger.log_config_validation_failure('secret_key', 'Missing required secret key')
62+
warn_lines(*production_error_message.lines(chomp: true))
5863
exit 1
5964
end
6065

@@ -79,9 +84,11 @@ def validate_secret_key!
7984
return unless secret == 'your-generated-secret-key-here' || secret.length < 32
8085

8186
SecurityLogger.log_config_validation_failure('secret_key', 'Invalid or weak secret key')
82-
puts '❌ CRITICAL: Invalid secret key for production deployment!'
83-
puts ' Secret key must be at least 32 characters and not the default placeholder.'
84-
puts ' Generate a secure key: openssl rand -hex 32'
87+
warn_lines(
88+
'CRITICAL: Invalid secret key for production deployment!',
89+
'Secret key must be at least 32 characters and not the default placeholder.',
90+
'Generate a secure key: openssl rand -hex 32'
91+
)
8592
exit 1
8693
end
8794

@@ -90,11 +97,35 @@ def validate_account_configuration!
9097
weak_tokens = accounts.select { |acc| acc[:token].length < 16 }
9198
return unless weak_tokens.any?
9299

100+
handle_weak_account_tokens!(weak_tokens)
101+
end
102+
103+
# @param lines [Array<String>]
104+
# @return [void]
105+
def warn_lines(*lines)
106+
lines.each { |line| Kernel.warn(line) }
107+
nil
108+
end
109+
110+
# @return [void]
111+
def log_development_default_secret_key_warning
112+
SecurityLogger.log_config_validation_failure(
113+
'secret_key',
114+
'Using development default secret key',
115+
severity: :warn
116+
)
117+
end
118+
119+
# @param weak_tokens [Array<Hash{Symbol=>String}>]
120+
# @return [void]
121+
def handle_weak_account_tokens!(weak_tokens)
93122
weak_usernames = weak_tokens.map { |acc| acc[:username] }.join(', ')
94123
SecurityLogger.log_config_validation_failure('account_tokens', "Weak tokens for users: #{weak_usernames}")
95-
puts '❌ CRITICAL: Weak authentication tokens detected in production!'
96-
puts ' All tokens must be at least 16 characters long.'
97-
puts " Weak tokens found for users: #{weak_usernames}"
124+
warn_lines(
125+
'CRITICAL: Weak authentication tokens detected in production!',
126+
'All tokens must be at least 16 characters long.',
127+
"Weak tokens found for users: #{weak_usernames}"
128+
)
98129
exit 1
99130
end
100131
end

app/web/request/request_context_middleware.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require 'rack/request'
44
require 'securerandom'
55
require 'time'
6+
require_relative '../security/log_sanitizer'
67

78
module Html2rss
89
module Web
@@ -57,7 +58,7 @@ def build_context(request)
5758
path = request.path_info.to_s
5859
RequestContext::Context.new(
5960
request_id: request_id_for(request),
60-
path: path,
61+
path: LogSanitizer.sanitize_path(path),
6162
http_method: request.request_method.to_s.upcase,
6263
route_group: route_group_for(path),
6364
actor: nil,

app/web/security/log_sanitizer.rb

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# frozen_string_literal: true
2+
3+
require 'digest'
4+
require 'html2rss/url'
5+
6+
module Html2rss
7+
module Web
8+
##
9+
# Sanitizes request paths and log payloads before they are emitted.
10+
module LogSanitizer
11+
FEED_TOKEN_ROUTE = %r{\A(/api/v1/feeds/)([^/?]+?)(\.(?:json|xml|rss))?\z}
12+
13+
class << self
14+
# @param path [String, nil]
15+
# @return [String, nil]
16+
def sanitize_path(path)
17+
return if path.nil?
18+
19+
path.to_s.gsub(FEED_TOKEN_ROUTE, '\1[REDACTED]\3')
20+
end
21+
22+
# @param details [Hash]
23+
# @return [Hash]
24+
def sanitize_details(details)
25+
details.each_with_object({}) do |(key, value), sanitized|
26+
sanitized[key] = sanitize_value(key, value)
27+
end
28+
end
29+
30+
private
31+
32+
# @param key [Object]
33+
# @param value [Object]
34+
# @return [Object]
35+
def sanitize_value(key, value)
36+
return sanitize_details(value) if value.is_a?(Hash)
37+
return value.map { |entry| sanitize_value(key, entry) } if value.is_a?(Array)
38+
return sanitize_url(value) if url_key?(key)
39+
40+
value
41+
end
42+
43+
# @param key [Object]
44+
# @return [Boolean]
45+
def url_key?(key)
46+
key_name = key.to_s
47+
key_name == 'url' || key_name.end_with?('_url', '_urls')
48+
end
49+
50+
# @param value [Object]
51+
# @return [Hash{Symbol=>Object}, Object]
52+
def sanitize_url(value)
53+
url = value.to_s
54+
return value if url.empty?
55+
56+
normalized_url = Html2rss::Url.for_channel(url)
57+
{
58+
host: normalized_url.host,
59+
scheme: normalized_url.scheme,
60+
hash: Digest::SHA256.hexdigest(url)[0..11]
61+
}.compact
62+
rescue StandardError
63+
{ hash: Digest::SHA256.hexdigest(url)[0..11] }
64+
end
65+
end
66+
end
67+
end
68+
end

app/web/security/security_logger.rb

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
11
# frozen_string_literal: true
22

3-
require 'logger'
4-
require 'json'
53
require 'digest'
6-
require 'time'
74
module Html2rss
85
module Web
96
##
107
# Security event logging for html2rss-web
118
# Provides structured logging for security events to stdout
129
module SecurityLogger
1310
class << self
14-
# Initialize logger to stdout with structured JSON output
15-
# @return [Logger]
16-
def logger
17-
Thread.current[:security_logger] ||= create_logger
18-
end
19-
20-
# Reset logger (for testing)
11+
# Reset shared logger state for tests.
2112
# @return [void]
2213
def reset_logger!
23-
Thread.current[:security_logger] = nil
14+
AppLogger.reset_logger!
2415
end
2516

2617
##
@@ -111,12 +102,13 @@ def log_blocked_request(ip, reason, endpoint)
111102
# Log configuration validation failure
112103
# @param component [String] component that failed validation
113104
# @param details [String] validation failure details
105+
# @param severity [Symbol]
114106
# @return [void]
115-
def log_config_validation_failure(component, details)
107+
def log_config_validation_failure(component, details, severity: :error)
116108
log_event('config_validation_failure', {
117109
component: component,
118110
details: details
119-
}, severity: :error)
111+
}, severity: severity)
120112
end
121113

122114
# Log lifecycle events for in-memory config/cache snapshots
@@ -134,32 +126,18 @@ def log_cache_lifecycle(component, event, details = {})
134126

135127
private
136128

137-
def create_logger
138-
Logger.new($stdout).tap do |log|
139-
log.formatter = proc do |severity, datetime, _progname, msg|
140-
"#{{
141-
timestamp: datetime.iso8601,
142-
level: severity,
143-
service: 'html2rss-web',
144-
**JSON.parse(msg, symbolize_names: true)
145-
}.to_json}\n"
146-
end
147-
end
148-
end
149-
150129
##
151130
# Log a security event
152131
# @param event_type [String] type of security event
153132
# @param data [Hash] event data
154133
def log_event(event_type, data, severity: :warn)
155-
context_data = RequestContext.current_h
156-
payload = {
157-
security_event: event_type,
158-
**context_data,
159-
**data
160-
}.to_json
161-
162-
logger.public_send(severity, payload)
134+
LogEvent.emit(
135+
level: severity,
136+
payload: {
137+
security_event: event_type,
138+
details: data
139+
}
140+
)
163141
rescue StandardError => error
164142
handle_logging_error(error, event_type, data)
165143
end
@@ -170,8 +148,9 @@ def log_event(event_type, data, severity: :warn)
170148
# @param event_type [String] type of security event
171149
# @param data [Hash] event data
172150
def handle_logging_error(error, event_type, data)
173-
Kernel.warn("Security logging error: #{error.message}")
174-
Kernel.warn("Security event: #{event_type} - #{data}")
151+
sanitized_data = LogSanitizer.sanitize_details(data)
152+
Kernel.warn("Structured logging fallback: #{error.class}: #{error.message}")
153+
Kernel.warn("component=security_logger security_event=#{event_type} details=#{sanitized_data}")
175154
end
176155
end
177156
end

app/web/telemetry/app_logger.rb

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# frozen_string_literal: true
2+
3+
require 'json'
4+
require 'logger'
5+
require 'time'
6+
7+
module Html2rss
8+
module Web
9+
##
10+
# Shared structured logger for application and middleware runtime events.
11+
module AppLogger
12+
class << self
13+
# @return [Logger]
14+
def logger
15+
Thread.current[:app_logger] ||= build_logger
16+
end
17+
18+
# @return [void]
19+
def reset_logger!
20+
Thread.current[:app_logger] = nil
21+
end
22+
23+
private
24+
25+
# @return [Logger]
26+
def build_logger
27+
Logger.new($stdout).tap do |log|
28+
log.formatter = method(:format_entry)
29+
end
30+
end
31+
32+
# @param severity [String]
33+
# @param datetime [Time]
34+
# @param _progname [String, nil]
35+
# @param message [String]
36+
# @return [String]
37+
def format_entry(severity, datetime, _progname, message)
38+
"#{base_payload(severity, datetime).merge(normalize_message(message)).to_json}\n"
39+
end
40+
41+
# @param severity [String]
42+
# @param datetime [Time]
43+
# @return [Hash{Symbol=>Object}]
44+
def base_payload(severity, datetime)
45+
{
46+
timestamp: datetime.iso8601,
47+
level: severity,
48+
service: 'html2rss-web'
49+
}
50+
end
51+
52+
# @param message [Object]
53+
# @return [Hash{Symbol=>Object}]
54+
def normalize_message(message)
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 }
59+
end
60+
61+
# @param message [String]
62+
# @return [Hash{Symbol=>Object}, nil]
63+
def parsed_json(message)
64+
JSON.parse(message, symbolize_names: true)
65+
rescue JSON::ParserError, TypeError
66+
nil
67+
end
68+
69+
# @param message [String]
70+
# @return [Boolean]
71+
def json_like?(message)
72+
stripped = message.lstrip
73+
stripped.start_with?('{', '[')
74+
end
75+
76+
# @param message [String]
77+
# @return [Hash{Symbol=>Object}, nil]
78+
def parse_logfmt(message)
79+
pairs = message.scan(/([a-zA-Z0-9_.-]+)=("[^"]*"|\S+)/)
80+
return nil if pairs.empty?
81+
82+
pairs.to_h do |key, raw_value|
83+
[key.to_sym, normalize_logfmt_value(raw_value)]
84+
end
85+
end
86+
87+
# @param raw_value [String]
88+
# @return [String, Integer, Float, TrueClass, FalseClass]
89+
def normalize_logfmt_value(raw_value)
90+
value = raw_value.delete_prefix('"').delete_suffix('"')
91+
return true if value == 'true'
92+
return false if value == 'false'
93+
return value.to_i if value.match?(/\A-?\d+\z/)
94+
return value.to_f if value.match?(/\A-?\d+\.\d+\z/)
95+
96+
value
97+
end
98+
end
99+
end
100+
end
101+
end

0 commit comments

Comments
 (0)