Skip to content

Commit ed2b3e9

Browse files
authored
fix(web): harden observability env handling and Sentry log redaction (#917)
## Summary - add runtime env parsing and validation for web boot - initialize Sentry in web boot with explicit enablement controls - redact sensitive log payloads before Sentry forwarding - add focused specs for app logger and sentry log forwarding ## Validation - make ready - attempted make dev smoke (blocked by existing port 4000 usage in local env)
1 parent 85e79bf commit ed2b3e9

23 files changed

Lines changed: 816 additions & 46 deletions

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,24 @@ This trial run is intentionally minimal. Use Docker Compose for Browserless, aut
4747

4848
## Deploy (Docker Compose)
4949

50-
1. Generate a key: `openssl rand -hex 32`.
51-
2. Export `HTML2RSS_SECRET_KEY`, `HEALTH_CHECK_TOKEN`, and `BROWSERLESS_IO_API_TOKEN` in your shell or `.env`.
52-
3. Start: `docker-compose up`.
50+
Quick start:
51+
52+
```bash
53+
export HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)"
54+
export HEALTH_CHECK_TOKEN="replace-with-a-strong-token"
55+
export BROWSERLESS_IO_API_TOKEN="replace-with-your-browserless-token"
56+
export BUILD_TAG="local"
57+
export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)"
58+
export AUTO_SOURCE_ENABLED=true
59+
docker-compose up
60+
```
61+
62+
Optional:
63+
64+
```bash
65+
export SENTRY_DSN="https://[email protected]/0"
66+
export SENTRY_ENABLE_LOGS=true
67+
```
5368

5469
UI + API run on `http://localhost:4000`. The app exits if the secret key is missing.
5570

Rakefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ desc 'Build and run docker image/container, and send requests to it'
4646
task :test do
4747
current_dir = ENV.fetch('GITHUB_WORKSPACE', __dir__)
4848
smoke_auto_source_enabled = ENV.fetch('SMOKE_AUTO_SOURCE_ENABLED', 'false')
49+
smoke_build_tag = ENV.fetch('SMOKE_BUILD_TAG', ENV.fetch('BUILD_TAG', 'docker-smoke'))
50+
smoke_git_sha = ENV.fetch('SMOKE_GIT_SHA', ENV.fetch('GITHUB_SHA', ENV.fetch('GIT_SHA', 'docker-smoke')))
4951
image_name = 'html2rss/web'
5052
skip_build = ENV.fetch('DOCKER_SMOKE_SKIP_BUILD', 'false') == 'true'
5153

@@ -60,6 +62,8 @@ task :test do
6062
'-d',
6163
'-p 4000:4000',
6264
'--env PUMA_LOG_CONFIG=1',
65+
"--env BUILD_TAG=#{smoke_build_tag}",
66+
"--env GIT_SHA=#{smoke_git_sha}",
6367
'--env HEALTH_CHECK_TOKEN=CHANGE_ME_HEALTH_CHECK_TOKEN',
6468
'--env HTML2RSS_SECRET_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
6569
"--env AUTO_SOURCE_ENABLED=#{smoke_auto_source_enabled}",

app/web/api/v1/health.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def authorize_health_check!(request)
7272
# @param request [Rack::Request]
7373
# @return [Boolean]
7474
def env_health_check_token?(request)
75-
configured_token = ENV.fetch('HEALTH_CHECK_TOKEN', '').to_s
75+
configured_token = RuntimeEnv.health_check_token.to_s
7676
provided_token = bearer_token(request)
7777
return false if configured_token.empty? || provided_token.nil?
7878
return false unless configured_token.bytesize == provided_token.bytesize

app/web/boot/sentry.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
module Html2rss
4+
module Web
5+
module Boot
6+
##
7+
# Configures Sentry boot-time error and structured log capture.
8+
module Sentry
9+
class << self
10+
# @return [void]
11+
def configure!
12+
return unless configure?
13+
14+
Bundler.require(:sentry)
15+
require 'sentry-ruby'
16+
initialize_sentry!
17+
end
18+
19+
private
20+
21+
# @return [Boolean]
22+
def configure?
23+
RuntimeEnv.sentry_enabled? && !sentry_initialized?
24+
end
25+
26+
# @return [void]
27+
def initialize_sentry!
28+
::Sentry.init do |config|
29+
apply_settings(config)
30+
end
31+
end
32+
33+
# @param config [Object]
34+
# @return [void]
35+
def apply_settings(config)
36+
config.dsn = RuntimeEnv.sentry_dsn
37+
config.environment = RuntimeEnv.rack_env
38+
config.enable_logs = RuntimeEnv.sentry_logs_enabled?
39+
config.send_default_pii = false
40+
config.release = release_name
41+
end
42+
43+
# @return [String]
44+
def release_name
45+
"#{RuntimeEnv.build_tag}+#{RuntimeEnv.git_sha}"
46+
end
47+
48+
# @return [Boolean]
49+
def sentry_initialized?
50+
defined?(::Sentry) && ::Sentry.respond_to?(:initialized?) && ::Sentry.initialized?
51+
end
52+
end
53+
end
54+
end
55+
end
56+
end

app/web/boot/setup.rb

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,24 @@ module Boot
66
##
77
# Applies boot-time runtime configuration outside the Roda class body.
88
module Setup
9+
RACK_TIMEOUT_BUFFER_SECONDS = 5
10+
911
class << self
12+
# @return [Boolean]
13+
def sentry_enabled?
14+
RuntimeEnv.sentry_enabled?
15+
end
16+
1017
# Validates environment configuration and wires the request service.
1118
#
1219
# @return [void]
1320
def call!
1421
validate_environment!
22+
capture_runtime_env!
23+
configure_sentry!
1524
configure_request_service!
1625
configure_runtime_logging!
26+
log_startup!
1727
end
1828

1929
private
@@ -25,9 +35,24 @@ def validate_environment!
2535
Flags.validate!
2636
end
2737

38+
# @return [void]
39+
def capture_runtime_env!
40+
RuntimeEnv.capture!
41+
end
42+
43+
# @return [void]
44+
def configure_sentry!
45+
Sentry.configure!
46+
end
47+
2848
# @return [void]
2949
def configure_request_service!
30-
nil
50+
return unless defined?(Rack::Timeout)
51+
return unless Rack::Timeout.respond_to?(:service_timeout=)
52+
53+
Rack::Timeout.service_timeout =
54+
Html2rss::RequestService::Policy::DEFAULTS[:total_timeout_seconds] +
55+
RACK_TIMEOUT_BUFFER_SECONDS
3156
end
3257

3358
# @return [void]
@@ -36,6 +61,19 @@ def configure_runtime_logging!
3661

3762
Rack::Timeout::Logger.logger = AppLogger.logger
3863
end
64+
65+
# @return [void]
66+
def log_startup!
67+
AppLogger.logger.info(
68+
{
69+
component: 'boot',
70+
event_name: 'app.start',
71+
build_tag: RuntimeEnv.build_tag,
72+
git_sha: RuntimeEnv.git_sha,
73+
sentry_enabled: RuntimeEnv.sentry_enabled?
74+
}.to_json
75+
)
76+
end
3977
end
4078
end
4179
end

app/web/config/environment_validator.rb

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ module Web
55
##
66
# Environment validation for html2rss-web
77
# Handles validation of environment variables and configuration
8-
module EnvironmentValidator
8+
module EnvironmentValidator # rubocop:disable Metrics/ModuleLength
9+
# rubocop:disable Metrics/ClassLength
910
class << self
1011
##
1112
# Validate required environment variables on startup
@@ -28,6 +29,7 @@ def validate_production_security!
2829

2930
validate_secret_key!
3031
validate_account_configuration!
32+
validate_build_metadata!
3133
end
3234

3335
# @return [Boolean]
@@ -92,6 +94,15 @@ def validate_secret_key!
9294
exit 1
9395
end
9496

97+
# @return [void]
98+
def validate_build_metadata!
99+
return unless missing_build_metadata?
100+
101+
log_missing_build_metadata!
102+
warn_lines(*missing_build_metadata_warning_lines)
103+
exit 1
104+
end
105+
95106
def validate_account_configuration!
96107
accounts = AccountManager.accounts
97108
weak_tokens = accounts.select { |acc| acc[:token].length < 16 }
@@ -128,7 +139,34 @@ def handle_weak_account_tokens!(weak_tokens)
128139
)
129140
exit 1
130141
end
142+
143+
# @return [Boolean]
144+
def missing_build_metadata?
145+
build_metadata_values.any?(&:empty?)
146+
end
147+
148+
# @return [Array<String>]
149+
def build_metadata_values
150+
%w[BUILD_TAG GIT_SHA].map { |key| ENV.fetch(key, '').strip }
151+
end
152+
153+
# @return [void]
154+
def log_missing_build_metadata!
155+
SecurityLogger.log_config_validation_failure(
156+
'build_metadata',
157+
'Missing BUILD_TAG or GIT_SHA'
158+
)
159+
end
160+
161+
# @return [Array<String>]
162+
def missing_build_metadata_warning_lines
163+
[
164+
'CRITICAL: Missing build metadata for production deployment!',
165+
'Set BUILD_TAG to the release build tag and GIT_SHA to the deployed commit SHA.'
166+
]
167+
end
131168
end
169+
# rubocop:enable Metrics/ClassLength
132170
end
133171
end
134172
end

app/web/config/runtime_env.rb

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# frozen_string_literal: true
2+
3+
module Html2rss
4+
module Web
5+
##
6+
# Captures boot-time environment configuration and scrubs selected secrets
7+
# from the process environment after validation.
8+
module RuntimeEnv
9+
SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].freeze
10+
BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV SENTRY_ENABLE_LOGS].freeze
11+
12+
class << self
13+
# @return [void]
14+
def capture!
15+
@values = tracked_env_values.freeze # rubocop:disable ThreadSafety/ClassInstanceVariable
16+
scrub_sensitive_env!
17+
nil
18+
end
19+
20+
# @return [void]
21+
def reset!
22+
@values = nil # rubocop:disable ThreadSafety/ClassInstanceVariable
23+
end
24+
25+
# @return [String]
26+
def secret_key
27+
fetch('HTML2RSS_SECRET_KEY')
28+
end
29+
30+
# @return [String]
31+
def health_check_token
32+
fetch('HEALTH_CHECK_TOKEN', '')
33+
end
34+
35+
# @return [String, nil]
36+
def sentry_dsn
37+
fetch('SENTRY_DSN', nil)
38+
end
39+
40+
# @return [Boolean]
41+
def sentry_enabled?
42+
!sentry_dsn.to_s.strip.empty?
43+
end
44+
45+
# @return [Boolean]
46+
def sentry_logs_enabled?
47+
parse_boolean(fetch('SENTRY_ENABLE_LOGS', 'false'), default: false)
48+
end
49+
50+
# @return [String]
51+
def build_tag
52+
fetch('BUILD_TAG', 'unknown')
53+
end
54+
55+
# @return [String]
56+
def git_sha
57+
fetch('GIT_SHA', 'unknown')
58+
end
59+
60+
# @return [String]
61+
def rack_env
62+
fetch('RACK_ENV', ENV.fetch('RACK_ENV', 'development'))
63+
end
64+
65+
private
66+
67+
# @param key [String]
68+
# @param default [Object]
69+
# @return [Object]
70+
def fetch(key, default = :__missing__)
71+
return ENV.fetch(key) if ENV.key?(key)
72+
73+
values = @values || {} # rubocop:disable ThreadSafety/ClassInstanceVariable
74+
return values.fetch(key) if values.key?(key)
75+
return default unless default == :__missing__
76+
77+
raise KeyError, "key not found: #{key}"
78+
end
79+
80+
# @return [Hash{String=>String}]
81+
def tracked_env_values
82+
(SENSITIVE_KEYS + BOOT_METADATA_KEYS).each_with_object({}) do |key, memo|
83+
memo[key] = ENV[key] if ENV.key?(key)
84+
end
85+
end
86+
87+
# @return [void]
88+
def scrub_sensitive_env!
89+
return nil if rack_env == 'test'
90+
91+
SENSITIVE_KEYS.each { |key| ENV.delete(key) }
92+
nil
93+
end
94+
95+
# @param value [Object]
96+
# @param default [Boolean]
97+
# @return [Boolean]
98+
def parse_boolean(value, default:)
99+
normalized = value.to_s.strip.downcase
100+
return true if normalized == 'true'
101+
return false if normalized == 'false'
102+
return default if normalized.empty?
103+
104+
raise ArgumentError, "Malformed env 'SENTRY_ENABLE_LOGS': expected true/false, got '#{value}'"
105+
end
106+
end
107+
end
108+
end
109+
end

app/web/security/auth.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def with_validated_token(feed_token, url)
117117

118118
# @return [String]
119119
def secret_key
120-
ENV.fetch('HTML2RSS_SECRET_KEY')
120+
RuntimeEnv.secret_key
121121
end
122122

123123
# @param username [String, nil]

0 commit comments

Comments
 (0)