diff --git a/README.md b/README.md index 85ec5130..3a75cc66 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,24 @@ This trial run is intentionally minimal. Use Docker Compose for Browserless, aut ## Deploy (Docker Compose) -1. Generate a key: `openssl rand -hex 32`. -2. Export `HTML2RSS_SECRET_KEY`, `HEALTH_CHECK_TOKEN`, and `BROWSERLESS_IO_API_TOKEN` in your shell or `.env`. -3. Start: `docker-compose up`. +Quick start: + +```bash +export HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)" +export HEALTH_CHECK_TOKEN="replace-with-a-strong-token" +export BROWSERLESS_IO_API_TOKEN="replace-with-your-browserless-token" +export BUILD_TAG="local" +export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)" +export AUTO_SOURCE_ENABLED=true +docker-compose up +``` + +Optional: + +```bash +export SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/0" +export SENTRY_ENABLE_LOGS=true +``` UI + API run on `http://localhost:4000`. The app exits if the secret key is missing. diff --git a/Rakefile b/Rakefile index 727158dd..c2da4b61 100644 --- a/Rakefile +++ b/Rakefile @@ -46,6 +46,8 @@ desc 'Build and run docker image/container, and send requests to it' task :test do current_dir = ENV.fetch('GITHUB_WORKSPACE', __dir__) smoke_auto_source_enabled = ENV.fetch('SMOKE_AUTO_SOURCE_ENABLED', 'false') + smoke_build_tag = ENV.fetch('SMOKE_BUILD_TAG', ENV.fetch('BUILD_TAG', 'docker-smoke')) + smoke_git_sha = ENV.fetch('SMOKE_GIT_SHA', ENV.fetch('GITHUB_SHA', ENV.fetch('GIT_SHA', 'docker-smoke'))) image_name = 'html2rss/web' skip_build = ENV.fetch('DOCKER_SMOKE_SKIP_BUILD', 'false') == 'true' @@ -60,6 +62,8 @@ task :test do '-d', '-p 4000:4000', '--env PUMA_LOG_CONFIG=1', + "--env BUILD_TAG=#{smoke_build_tag}", + "--env GIT_SHA=#{smoke_git_sha}", '--env HEALTH_CHECK_TOKEN=CHANGE_ME_HEALTH_CHECK_TOKEN', '--env HTML2RSS_SECRET_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', "--env AUTO_SOURCE_ENABLED=#{smoke_auto_source_enabled}", diff --git a/app/web/api/v1/health.rb b/app/web/api/v1/health.rb index ff7a325b..41299803 100644 --- a/app/web/api/v1/health.rb +++ b/app/web/api/v1/health.rb @@ -72,7 +72,7 @@ def authorize_health_check!(request) # @param request [Rack::Request] # @return [Boolean] def env_health_check_token?(request) - configured_token = ENV.fetch('HEALTH_CHECK_TOKEN', '').to_s + configured_token = RuntimeEnv.health_check_token.to_s provided_token = bearer_token(request) return false if configured_token.empty? || provided_token.nil? return false unless configured_token.bytesize == provided_token.bytesize diff --git a/app/web/boot/sentry.rb b/app/web/boot/sentry.rb new file mode 100644 index 00000000..e69dc82b --- /dev/null +++ b/app/web/boot/sentry.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Boot + ## + # Configures Sentry boot-time error and structured log capture. + module Sentry + class << self + # @return [void] + def configure! + return unless configure? + + Bundler.require(:sentry) + require 'sentry-ruby' + initialize_sentry! + end + + private + + # @return [Boolean] + def configure? + RuntimeEnv.sentry_enabled? && !sentry_initialized? + end + + # @return [void] + def initialize_sentry! + ::Sentry.init do |config| + apply_settings(config) + end + end + + # @param config [Object] + # @return [void] + def apply_settings(config) + config.dsn = RuntimeEnv.sentry_dsn + config.environment = RuntimeEnv.rack_env + config.enable_logs = RuntimeEnv.sentry_logs_enabled? + config.send_default_pii = false + config.release = release_name + end + + # @return [String] + def release_name + "#{RuntimeEnv.build_tag}+#{RuntimeEnv.git_sha}" + end + + # @return [Boolean] + def sentry_initialized? + defined?(::Sentry) && ::Sentry.respond_to?(:initialized?) && ::Sentry.initialized? + end + end + end + end + end +end diff --git a/app/web/boot/setup.rb b/app/web/boot/setup.rb index edb5687d..cf807ecf 100644 --- a/app/web/boot/setup.rb +++ b/app/web/boot/setup.rb @@ -6,14 +6,24 @@ module Boot ## # Applies boot-time runtime configuration outside the Roda class body. module Setup + RACK_TIMEOUT_BUFFER_SECONDS = 5 + class << self + # @return [Boolean] + def sentry_enabled? + RuntimeEnv.sentry_enabled? + end + # Validates environment configuration and wires the request service. # # @return [void] def call! validate_environment! + capture_runtime_env! + configure_sentry! configure_request_service! configure_runtime_logging! + log_startup! end private @@ -25,9 +35,24 @@ def validate_environment! Flags.validate! end + # @return [void] + def capture_runtime_env! + RuntimeEnv.capture! + end + + # @return [void] + def configure_sentry! + Sentry.configure! + end + # @return [void] def configure_request_service! - nil + return unless defined?(Rack::Timeout) + return unless Rack::Timeout.respond_to?(:service_timeout=) + + Rack::Timeout.service_timeout = + Html2rss::RequestService::Policy::DEFAULTS[:total_timeout_seconds] + + RACK_TIMEOUT_BUFFER_SECONDS end # @return [void] @@ -36,6 +61,19 @@ def configure_runtime_logging! Rack::Timeout::Logger.logger = AppLogger.logger end + + # @return [void] + def log_startup! + AppLogger.logger.info( + { + component: 'boot', + event_name: 'app.start', + build_tag: RuntimeEnv.build_tag, + git_sha: RuntimeEnv.git_sha, + sentry_enabled: RuntimeEnv.sentry_enabled? + }.to_json + ) + end end end end diff --git a/app/web/config/environment_validator.rb b/app/web/config/environment_validator.rb index 4d80ee1a..11647188 100644 --- a/app/web/config/environment_validator.rb +++ b/app/web/config/environment_validator.rb @@ -5,7 +5,8 @@ module Web ## # Environment validation for html2rss-web # Handles validation of environment variables and configuration - module EnvironmentValidator + module EnvironmentValidator # rubocop:disable Metrics/ModuleLength + # rubocop:disable Metrics/ClassLength class << self ## # Validate required environment variables on startup @@ -28,6 +29,7 @@ def validate_production_security! validate_secret_key! validate_account_configuration! + validate_build_metadata! end # @return [Boolean] @@ -92,6 +94,15 @@ def validate_secret_key! exit 1 end + # @return [void] + def validate_build_metadata! + return unless missing_build_metadata? + + log_missing_build_metadata! + warn_lines(*missing_build_metadata_warning_lines) + exit 1 + end + def validate_account_configuration! accounts = AccountManager.accounts weak_tokens = accounts.select { |acc| acc[:token].length < 16 } @@ -128,7 +139,34 @@ def handle_weak_account_tokens!(weak_tokens) ) exit 1 end + + # @return [Boolean] + def missing_build_metadata? + build_metadata_values.any?(&:empty?) + end + + # @return [Array] + def build_metadata_values + %w[BUILD_TAG GIT_SHA].map { |key| ENV.fetch(key, '').strip } + end + + # @return [void] + def log_missing_build_metadata! + SecurityLogger.log_config_validation_failure( + 'build_metadata', + 'Missing BUILD_TAG or GIT_SHA' + ) + end + + # @return [Array] + def missing_build_metadata_warning_lines + [ + 'CRITICAL: Missing build metadata for production deployment!', + 'Set BUILD_TAG to the release build tag and GIT_SHA to the deployed commit SHA.' + ] + end end + # rubocop:enable Metrics/ClassLength end end end diff --git a/app/web/config/runtime_env.rb b/app/web/config/runtime_env.rb new file mode 100644 index 00000000..0760ad83 --- /dev/null +++ b/app/web/config/runtime_env.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Captures boot-time environment configuration and scrubs selected secrets + # from the process environment after validation. + module RuntimeEnv + SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].freeze + BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV SENTRY_ENABLE_LOGS].freeze + + class << self + # @return [void] + def capture! + @values = tracked_env_values.freeze # rubocop:disable ThreadSafety/ClassInstanceVariable + scrub_sensitive_env! + nil + end + + # @return [void] + def reset! + @values = nil # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + # @return [String] + def secret_key + fetch('HTML2RSS_SECRET_KEY') + end + + # @return [String] + def health_check_token + fetch('HEALTH_CHECK_TOKEN', '') + end + + # @return [String, nil] + def sentry_dsn + fetch('SENTRY_DSN', nil) + end + + # @return [Boolean] + def sentry_enabled? + !sentry_dsn.to_s.strip.empty? + end + + # @return [Boolean] + def sentry_logs_enabled? + parse_boolean(fetch('SENTRY_ENABLE_LOGS', 'false'), default: false) + end + + # @return [String] + def build_tag + fetch('BUILD_TAG', 'unknown') + end + + # @return [String] + def git_sha + fetch('GIT_SHA', 'unknown') + end + + # @return [String] + def rack_env + fetch('RACK_ENV', ENV.fetch('RACK_ENV', 'development')) + end + + private + + # @param key [String] + # @param default [Object] + # @return [Object] + def fetch(key, default = :__missing__) + return ENV.fetch(key) if ENV.key?(key) + + values = @values || {} # rubocop:disable ThreadSafety/ClassInstanceVariable + return values.fetch(key) if values.key?(key) + return default unless default == :__missing__ + + raise KeyError, "key not found: #{key}" + end + + # @return [Hash{String=>String}] + def tracked_env_values + (SENSITIVE_KEYS + BOOT_METADATA_KEYS).each_with_object({}) do |key, memo| + memo[key] = ENV[key] if ENV.key?(key) + end + end + + # @return [void] + def scrub_sensitive_env! + return nil if rack_env == 'test' + + SENSITIVE_KEYS.each { |key| ENV.delete(key) } + nil + end + + # @param value [Object] + # @param default [Boolean] + # @return [Boolean] + def parse_boolean(value, default:) + normalized = value.to_s.strip.downcase + return true if normalized == 'true' + return false if normalized == 'false' + return default if normalized.empty? + + raise ArgumentError, "Malformed env 'SENTRY_ENABLE_LOGS': expected true/false, got '#{value}'" + end + end + end + end +end diff --git a/app/web/security/auth.rb b/app/web/security/auth.rb index 9f16ef75..528d2fa0 100644 --- a/app/web/security/auth.rb +++ b/app/web/security/auth.rb @@ -117,7 +117,7 @@ def with_validated_token(feed_token, url) # @return [String] def secret_key - ENV.fetch('HTML2RSS_SECRET_KEY') + RuntimeEnv.secret_key end # @param username [String, nil] diff --git a/app/web/telemetry/app_logger.rb b/app/web/telemetry/app_logger.rb index 7281c5dc..8724bab6 100644 --- a/app/web/telemetry/app_logger.rb +++ b/app/web/telemetry/app_logger.rb @@ -35,7 +35,9 @@ def build_logger # @param message [String] # @return [String] def format_entry(severity, datetime, _progname, message) - "#{base_payload(severity, datetime).merge(normalize_message(message)).to_json}\n" + payload = base_payload(severity, datetime).merge(normalize_message(message)) + emit_to_sentry(payload) + "#{payload.to_json}\n" end # @param severity [String] @@ -95,6 +97,22 @@ def normalize_logfmt_value(raw_value) value end + + # @param payload [Hash{Symbol=>Object}] + # @return [void] + def emit_to_sentry(payload) + return unless sentry_payload?(payload) + + SentryLogs.emit(payload) + rescue StandardError + nil + end + + # @param payload [Hash{Symbol=>Object}] + # @return [Boolean] + def sentry_payload?(payload) + payload.key?(:event_name) || payload.key?(:security_event) + end end end end diff --git a/app/web/telemetry/sentry_logs.rb b/app/web/telemetry/sentry_logs.rb new file mode 100644 index 00000000..f6d55353 --- /dev/null +++ b/app/web/telemetry/sentry_logs.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Mirrors structured application logs into Sentry when log intake is + # enabled for the current runtime. + module SentryLogs + OMIT = Object.new.freeze + ALLOWED_LEVELS = %i[debug info warn error fatal].freeze + SENSITIVE_ATTRIBUTE_KEYS = %w[actor email ip remote_ip user_agent username x_forwarded_for].freeze + + class << self + # @param payload [Hash{Symbol=>Object}] + # @return [void] + def emit(payload) + return unless enabled? + + logger.public_send(level(payload), message(payload), **attributes(payload)) + rescue StandardError + nil + end + + private + + # @return [Boolean] + def enabled? + RuntimeEnv.sentry_enabled? && + RuntimeEnv.sentry_logs_enabled? && + defined?(::Sentry) && + !logger.nil? + end + + # @return [Object, nil] + def logger + return unless defined?(::Sentry) && ::Sentry.respond_to?(:logger) + + ::Sentry.logger + end + + # @param payload [Hash{Symbol=>Object}] + # @return [Symbol] + def level(payload) + requested_level = payload.fetch(:level, 'INFO').to_s.downcase.to_sym + return requested_level if ALLOWED_LEVELS.include?(requested_level) + + :info + end + + # @param payload [Hash{Symbol=>Object}] + # @return [String] + def message(payload) + payload[:message] || payload[:event_name] || payload[:security_event] || + payload[:component] || 'html2rss-web log' + end + + # @param payload [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def attributes(payload) + sanitize_hash(payload).tap do |attributes| + attributes.delete(:message) + attributes.delete(:level) + end + end + + # @param payload [Hash] + # @return [Hash] + def sanitize_hash(payload) + payload.each_with_object({}) do |(key, value), sanitized| + sanitized_value = sanitize_value(key, value) + next if sanitized_value.equal?(OMIT) + + sanitized[key] = sanitized_value + end + end + + # @param key [Object] + # @param value [Object] + # @return [Object] + def sanitize_value(key, value) + return OMIT if sensitive_key?(key) + return sanitize_hash(value) if value.is_a?(Hash) + return sanitize_array(key, value) if value.is_a?(Array) + + value + end + + # @param key [Object] + # @param values [Array] + # @return [Array] + def sanitize_array(key, values) + values.map { |entry| sanitize_value(key, entry) } + .reject { |entry| entry.equal?(OMIT) } + end + + # @param key [Object] + # @return [Boolean] + def sensitive_key?(key) + SENSITIVE_ATTRIBUTE_KEYS.include?(key.to_s) + end + end + end + end +end diff --git a/config.ru b/config.ru index 726d1fd1..afc1875f 100644 --- a/config.ru +++ b/config.ru @@ -4,26 +4,13 @@ require 'rubygems' require 'bundler/setup' require 'rack-timeout' require_relative 'app/web/boot/development_reloader' +require_relative 'app' -if ENV.key?('SENTRY_DSN') - Bundler.require(:sentry) - require 'sentry-ruby' - - Sentry.init do |config| - config.dsn = ENV.fetch('SENTRY_DSN') - - config.traces_sample_rate = 1.0 - config.profiles_sample_rate = 1.0 - end - - use Sentry::Rack::CaptureExceptions -end +use Sentry::Rack::CaptureExceptions if Html2rss::Web::Boot::Setup.sentry_enabled? dev = ENV.fetch('RACK_ENV', nil) == 'development' if dev - require_relative 'app' - run Html2rss::Web::Boot::DevelopmentReloader.new( loader: Html2rss::Web::Boot.loader, app_provider: -> { Html2rss::Web::App.app } @@ -31,7 +18,6 @@ if dev else use Rack::Timeout - require_relative 'app' Html2rss::Web::Boot.eager_load! run(Html2rss::Web::App.freeze.app) diff --git a/docker-compose.yml b/docker-compose.yml index 38273eaa..95eeaee2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,12 @@ services: environment: RACK_ENV: production PORT: 4000 + BUILD_TAG: ${BUILD_TAG:?set BUILD_TAG} + GIT_SHA: ${GIT_SHA:?set GIT_SHA} HTML2RSS_SECRET_KEY: ${HTML2RSS_SECRET_KEY:?set HTML2RSS_SECRET_KEY} HEALTH_CHECK_TOKEN: ${HEALTH_CHECK_TOKEN:?set HEALTH_CHECK_TOKEN} + SENTRY_DSN: ${SENTRY_DSN:-} + SENTRY_ENABLE_LOGS: ${SENTRY_ENABLE_LOGS:-false} BROWSERLESS_IO_WEBSOCKET_URL: ws://browserless:4002 BROWSERLESS_IO_API_TOKEN: ${BROWSERLESS_IO_API_TOKEN:?set BROWSERLESS_IO_API_TOKEN} # Trial runs use the image's bundled config/feeds.yml. diff --git a/docs/README.md b/docs/README.md index d971cd39..318d3afb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -168,12 +168,16 @@ Managed flags and environment keys: | `async_feed_refresh_enabled` | `ASYNC_FEED_REFRESH_ENABLED` | boolean | `false` | | `async_feed_refresh_stale_factor` | `ASYNC_FEED_REFRESH_STALE_FACTOR` | integer `>= 1` | `3` | | `health_check_token` | `HEALTH_CHECK_TOKEN` | string | `nil` | +| `build_tag` | `BUILD_TAG` | string | `unknown` outside production | +| `git_sha` | `GIT_SHA` | string | `unknown` outside production | | `sentry_dsn` | `SENTRY_DSN` | string | `nil` | +| `sentry_enable_logs` | `SENTRY_ENABLE_LOGS` | boolean | `false` | Rules: - Invalid managed flag values must fail fast at boot. - Unknown managed feature-style env keys must fail fast at boot. +- `BUILD_TAG` and `GIT_SHA` are required in production so startup logs can identify the deployed build. - Add or change flags in code, tests, and this table together. --- diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 49422cdd..91da5024 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -124,7 +124,7 @@ export type RenderFeedByTokenErrors = { */ 403: string; /** - * returns non-cacheable json feed errors when service generation fails + * returns non-cacheable feed errors when service generation fails */ 500: string; }; @@ -177,7 +177,7 @@ export type GetHealthStatusError = GetHealthStatusErrors[keyof GetHealthStatusEr export type GetHealthStatusResponses = { /** - * returns health status when the configured environment token is valid + * returns health status when token is valid */ 200: { data: { diff --git a/public/openapi.yaml b/public/openapi.yaml index 714d08fd..b94f4552 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -277,8 +277,7 @@ paths: ErrorInternal Server Error schema: type: string - description: returns non-cacheable json feed errors when service generation - fails + description: returns non-cacheable feed errors when service generation fails security: [] summary: Render feed by token tags: @@ -331,8 +330,7 @@ paths: - success - data type: object - description: returns health status when the configured environment token - is valid + description: returns health status when token is valid '401': content: application/json: diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 894ce2f5..aebed989 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -214,7 +214,7 @@ def expected_featured_feeds expect(json.dig('data', 'health', 'status')).to eq('healthy') end - it 'returns health status when the configured environment token is valid', :aggregate_failures do + it 'returns health status when the configured environment token is valid', :aggregate_failures, openapi: false do ClimateControl.modify(HEALTH_CHECK_TOKEN: 'rotated-health-token') do allow(Html2rss::Web::Auth).to receive(:authenticate).and_call_original @@ -227,6 +227,21 @@ def expected_featured_feeds end end + it 'returns health status after production-style env scrubbing', :aggregate_failures, openapi: false do + capture_scrubbed_runtime_env( + 'RACK_ENV' => 'production', + 'HEALTH_CHECK_TOKEN' => 'scrubbed-health-token' + ) do + header 'Authorization', 'Bearer scrubbed-health-token' + get '/api/v1/health' + + expect(ENV.fetch('HEALTH_CHECK_TOKEN', nil)).to be_nil + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect(json.dig('data', 'health', 'status')).to eq('healthy') + end + end + it 'returns error when configuration fails', :aggregate_failures do allow(Html2rss::Web::Auth).to receive(:authenticate).and_return({ username: 'health-check' }) allow(Html2rss::Web::LocalConfig).to receive(:yaml).and_raise(StandardError, 'boom') @@ -395,7 +410,7 @@ def expected_featured_feeds ) end - it 'returns non-cacheable xml feed errors when service generation fails', :aggregate_failures do + it 'returns non-cacheable feed errors when service generation fails', :aggregate_failures do unique_url = "#{feed_url}/service-error-xml" token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') @@ -409,7 +424,7 @@ def expected_featured_feeds expect(last_response.body).to include('Internal Server Error') end - it 'returns non-cacheable json feed errors when service generation fails', :aggregate_failures do + it 'returns non-cacheable json feed errors when service generation fails', :aggregate_failures, openapi: false do unique_url = "#{feed_url}/service-error-json" token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index 73df26b3..86f3a6c4 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' require 'rack/test' require 'cgi' +require 'climate_control' require 'json' require 'securerandom' require_relative '../../../app' @@ -11,7 +12,6 @@ include Rack::Test::Methods let(:app) { described_class.freeze.app } - let(:secret_key) { ENV.fetch('HTML2RSS_SECRET_KEY') } let(:feed_url) { 'https://example.com/articles' } let(:feed_token) do @@ -111,6 +111,20 @@ expect(last_response.headers['Vary']).to include('Accept') end + it 'validates feed tokens after production-style env scrubbing', :aggregate_failures do + capture_scrubbed_runtime_env( + 'RACK_ENV' => 'production', + 'HTML2RSS_SECRET_KEY' => 'scrubbed-secret-key-for-request-specs' + ) do + generated_token = Html2rss::Web::Auth.generate_feed_token(account[:username], feed_url, strategy: 'faraday') + get "/api/v1/feeds/#{generated_token}", {}, { 'HTTP_ACCEPT' => 'application/xml' } + + expect(ENV.fetch('HTML2RSS_SECRET_KEY', nil)).to be_nil + expect(last_response.status).to eq(200) + expect(last_response.headers['Content-Type']).to eq('application/xml') + end + end + it 'prefers the path extension over Accept negotiation' do header 'Accept', 'application/feed+json' get "/api/v1/feeds/#{feed_token}.xml" diff --git a/spec/html2rss/web/app_logger_spec.rb b/spec/html2rss/web/app_logger_spec.rb new file mode 100644 index 00000000..a2557126 --- /dev/null +++ b/spec/html2rss/web/app_logger_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'stringio' + +require_relative '../../../app/web/config/runtime_env' +require_relative '../../../app/web/telemetry/sentry_logs' +require_relative '../../../app/web/telemetry/app_logger' + +RSpec.describe Html2rss::Web::AppLogger do + let(:io) { StringIO.new } + let(:test_logger) { Logger.new(io).tap { |log| log.formatter = described_class.send(:method, :format_entry) } } + + describe '.logger' do + it 'forwards structured logs to the Sentry log bridge', :aggregate_failures do + allow(Logger).to receive(:new).and_return(test_logger) + allow(Html2rss::Web::SentryLogs).to receive(:emit) + + described_class.reset_logger! + described_class.logger.info({ event_name: 'boot.test', component: 'boot' }.to_json) + + expect(Html2rss::Web::SentryLogs).to have_received(:emit).with( + include(component: 'boot', event_name: 'boot.test', service: 'html2rss-web') + ) + expect(io.string).to include('"event_name":"boot.test"') + end + + it 'still writes structured logs when the Sentry bridge raises' do + allow(Logger).to receive(:new).and_return(test_logger) + allow(Html2rss::Web::SentryLogs).to receive(:emit).and_raise(StandardError, 'boom') + + described_class.reset_logger! + expect do + described_class.logger.info({ event_name: 'boot.test', component: 'boot' }.to_json) + end.not_to raise_error + expect(io.string).to include('"event_name":"boot.test"') + end + + it 'does not forward plain string logs to the Sentry bridge' do + allow(Logger).to receive(:new).and_return(test_logger) + allow(Html2rss::Web::SentryLogs).to receive(:emit) + + described_class.reset_logger! + described_class.logger.info('plain-text log line with request details') + + expect(Html2rss::Web::SentryLogs).not_to have_received(:emit) + expect(io.string).to include('"message":"plain-text log line with request details"') + end + end +end diff --git a/spec/html2rss/web/boot/setup_spec.rb b/spec/html2rss/web/boot/setup_spec.rb index c5c2461f..433b10aa 100644 --- a/spec/html2rss/web/boot/setup_spec.rb +++ b/spec/html2rss/web/boot/setup_spec.rb @@ -1,18 +1,33 @@ # frozen_string_literal: true require 'spec_helper' +require 'climate_control' require_relative '../../../../app' RSpec.describe Html2rss::Web::Boot::Setup do + let(:boot_secret_key) { 'secret-key-123456789012345678901234' } + let(:sentry_dsn) { 'https://example@sentry.invalid/1' } + let(:boot_env) do + { + 'RACK_ENV' => 'development', + 'HTML2RSS_SECRET_KEY' => boot_secret_key, + 'BUILD_TAG' => '2026-03-27', + 'GIT_SHA' => 'abc1234', + 'SENTRY_ENABLE_LOGS' => nil + } + end + before do - allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!) - allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!) allow(Html2rss::Web::Flags).to receive(:validate!) + allow(Html2rss::Web::Boot::Sentry).to receive(:configure!) end describe '.call!' do it 'validates environment state', :aggregate_failures do + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!) + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!) + described_class.call! expect(Html2rss::Web::EnvironmentValidator).to have_received(:validate_environment!).once @@ -30,5 +45,151 @@ expect(Rack::Timeout::Logger.logger).to be(Html2rss::Web::AppLogger.logger) end + + it 'captures and scrubs sensitive env vars after validation', :aggregate_failures do + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!).ordered do + expect(ENV.fetch('HTML2RSS_SECRET_KEY', nil)).to eq(boot_secret_key) + expect(ENV.fetch('HEALTH_CHECK_TOKEN', nil)).to eq('health-token') + end + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!).ordered do + expect(ENV.fetch('SENTRY_DSN', nil)).to eq(sentry_dsn) + end + + ClimateControl.modify(scrubbed_env) do + described_class.call! + + expect_runtime_env_to_match_boot_values + expect_sensitive_env_to_be_scrubbed + end + end + + it 'configures Sentry for error reporting when a DSN is present', :aggregate_failures do + stub_environment_validation + allow(Html2rss::Web::Boot::Sentry).to receive(:configure!).and_call_original + allow(Html2rss::Web::Boot::Sentry).to receive(:require).with('sentry-ruby').and_return(true) + allow(Bundler).to receive(:require) + fake_sentry = build_fake_sentry + stub_const('Sentry', fake_sentry) + + ClimateControl.modify(boot_env.merge('SENTRY_DSN' => sentry_dsn)) do + described_class.call! + end + + expect_sentry_to_be_configured + end + + it 'enables Sentry logs when SENTRY_ENABLE_LOGS is true', :aggregate_failures do + stub_environment_validation + allow(Html2rss::Web::Boot::Sentry).to receive(:configure!).and_call_original + allow(Html2rss::Web::Boot::Sentry).to receive(:require).with('sentry-ruby').and_return(true) + allow(Bundler).to receive(:require) + fake_sentry = build_fake_sentry + stub_const('Sentry', fake_sentry) + + ClimateControl.modify(boot_env.merge('SENTRY_DSN' => sentry_dsn, 'SENTRY_ENABLE_LOGS' => 'true')) do + described_class.call! + end + + expect_sentry_config(:enable_logs, true) + end + + it 'fails fast when SENTRY_ENABLE_LOGS is malformed' do + stub_environment_validation + allow(Html2rss::Web::Boot::Sentry).to receive(:configure!).and_call_original + allow(Html2rss::Web::Boot::Sentry).to receive(:require).with('sentry-ruby').and_return(true) + allow(Bundler).to receive(:require) + fake_sentry = build_fake_sentry + stub_const('Sentry', fake_sentry) + + expect do + ClimateControl.modify(boot_env.merge('SENTRY_DSN' => sentry_dsn, 'SENTRY_ENABLE_LOGS' => '1')) do + described_class.call! + end + end.to raise_error(ArgumentError, /SENTRY_ENABLE_LOGS/) + end + + it 'logs build metadata on startup' do + stub_environment_validation + logger = instance_double(Logger, info: nil) + allow(Html2rss::Web::AppLogger).to receive(:logger).and_return(logger) + + ClimateControl.modify(boot_env) do + described_class.call! + end + + expect(logger).to have_received(:info).with( + a_string_including('"build_tag":"2026-03-27"', '"git_sha":"abc1234"', '"event_name":"app.start"') + ) + end + end + + def stub_environment_validation + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!) + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!) + end + + def scrubbed_env + boot_env.merge( + 'HEALTH_CHECK_TOKEN' => 'health-token', + 'SENTRY_DSN' => sentry_dsn + ) + end + + def expect_runtime_env_to_match_boot_values + expect(Html2rss::Web::RuntimeEnv.secret_key).to eq(boot_secret_key) + expect(Html2rss::Web::RuntimeEnv.health_check_token).to eq('health-token') + expect(Html2rss::Web::RuntimeEnv.sentry_dsn).to eq(sentry_dsn) + end + + def expect_sensitive_env_to_be_scrubbed + expect(ENV.fetch('HTML2RSS_SECRET_KEY', nil)).to be_nil + expect(ENV.fetch('HEALTH_CHECK_TOKEN', nil)).to be_nil + expect(ENV.fetch('SENTRY_DSN', nil)).to be_nil + end + + def expect_sentry_to_be_configured + expect(Bundler).to have_received(:require).with(:sentry) + expect_sentry_config(:dsn, sentry_dsn) + expect_sentry_config(:enable_logs, false) + expect_sentry_config(:release, '2026-03-27+abc1234') + end + + def build_fake_sentry + captured_config = nil + fake_logger = build_fake_sentry_logger + config_factory = method(:build_fake_sentry_config) + + Module.new.tap do |fake_sentry| + define_fake_sentry_accessors(fake_sentry, fake_logger, -> { captured_config }) + define_fake_sentry_init(fake_sentry, config_factory, ->(config) { captured_config = config }) + end + end + + def define_fake_sentry_accessors(fake_sentry, fake_logger, captured_config) + fake_sentry.define_singleton_method(:initialized?) { false } + fake_sentry.define_singleton_method(:logger) { fake_logger } + fake_sentry.define_singleton_method(:captured_config) { captured_config.call } + end + + def define_fake_sentry_init(fake_sentry, config_factory, assign_config) + fake_sentry.define_singleton_method(:init) do |&block| + config = config_factory.call + assign_config.call(config) + block.call(config) + end + end + + def expect_sentry_config(attribute, expected_value) + expect(Sentry.captured_config.public_send(attribute)).to eq(expected_value) + end + + def build_fake_sentry_logger + Class.new do + def info(*) = nil + end.new + end + + def build_fake_sentry_config + Struct.new(:dsn, :environment, :enable_logs, :send_default_pii, :release).new end end diff --git a/spec/html2rss/web/environment_validator_spec.rb b/spec/html2rss/web/environment_validator_spec.rb index fa7e0248..75e3b4c7 100644 --- a/spec/html2rss/web/environment_validator_spec.rb +++ b/spec/html2rss/web/environment_validator_spec.rb @@ -5,13 +5,18 @@ require_relative '../../../app/web/config/environment_validator' require_relative '../../../app/web/config/flags' +require_relative '../../../app/web/security/account_manager' require_relative '../../../app/web/security/security_logger' RSpec.describe Html2rss::Web::EnvironmentValidator do + def stub_validation_logging + allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) + allow(Kernel).to receive(:warn) + end + describe '.validate_environment!' do it 'sets a development default secret key without exiting' do - allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) - allow(Kernel).to receive(:warn) + stub_validation_logging ClimateControl.modify('RACK_ENV' => 'development', 'HTML2RSS_SECRET_KEY' => nil) do described_class.validate_environment! @@ -20,8 +25,7 @@ end it 'logs development default secret key warnings' do - allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) - allow(Kernel).to receive(:warn) + stub_validation_logging ClimateControl.modify('RACK_ENV' => 'development', 'HTML2RSS_SECRET_KEY' => nil) do described_class.validate_environment! @@ -32,8 +36,7 @@ end it 'logs missing production secret key failures before exiting' do - allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) - allow(Kernel).to receive(:warn) + stub_validation_logging ClimateControl.modify('RACK_ENV' => 'production', 'HTML2RSS_SECRET_KEY' => nil) do expect { described_class.validate_environment! }.to raise_error(SystemExit) @@ -46,16 +49,37 @@ describe '.validate_production_security!' do it 'logs weak production secret keys before exiting' do - allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) - allow(Kernel).to receive(:warn) - - ClimateControl.modify('RACK_ENV' => 'production', 'HTML2RSS_SECRET_KEY' => 'short-secret') do + stub_validation_logging + + ClimateControl.modify( + 'RACK_ENV' => 'production', + 'HTML2RSS_SECRET_KEY' => 'short-secret', + 'BUILD_TAG' => '2026-03-27', + 'GIT_SHA' => 'abc1234' + ) do expect { described_class.validate_production_security! }.to raise_error(SystemExit) end expect(Html2rss::Web::SecurityLogger).to have_received(:log_config_validation_failure) .with('secret_key', 'Invalid or weak secret key') end + + it 'logs missing build metadata before exiting' do + stub_validation_logging + allow(Html2rss::Web::AccountManager).to receive(:accounts).and_return([]) + + ClimateControl.modify( + 'RACK_ENV' => 'production', + 'HTML2RSS_SECRET_KEY' => '0123456789abcdef0123456789abcdef', + 'BUILD_TAG' => nil, + 'GIT_SHA' => nil + ) do + expect { described_class.validate_production_security! }.to raise_error(SystemExit) + end + + expect(Html2rss::Web::SecurityLogger).to have_received(:log_config_validation_failure) + .with('build_metadata', 'Missing BUILD_TAG or GIT_SHA') + end end describe '.auto_source_enabled?' do diff --git a/spec/html2rss/web/sentry_logs_spec.rb b/spec/html2rss/web/sentry_logs_spec.rb new file mode 100644 index 00000000..27d5bb28 --- /dev/null +++ b/spec/html2rss/web/sentry_logs_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative '../../../app/web/config/runtime_env' +require_relative '../../../app/web/telemetry/sentry_logs' + +RSpec.describe Html2rss::Web::SentryLogs do + let(:logger_class) do + Struct.new(:captured_call) do + %i[debug info warn error fatal].each do |log_level| + define_method(log_level) do |message, **attributes| + captured_call[:level] = log_level + captured_call[:message] = message + captured_call[:attributes] = attributes + end + end + end + end + + let(:captured_call) { {} } + let(:sentry_logger) { build_sentry_logger } + let(:fake_sentry) do + Module.new.tap do |mod| + mod.define_singleton_method(:logger) { sentry_logger } + end + end + let(:raw_payload) do + { + event_name: 'auth.authenticate', + actor: 'alice', + details: { + username: 'alice', + ip: '127.0.0.1', + user_agent: 'curl/8.7.1', + reason: 'missing_token', + nested: [{ username: 'bob', ip: '10.0.0.2' }] + } + } + end + + it 'filters auth and security pii before forwarding payloads to Sentry', :aggregate_failures do + stub_const('Sentry', fake_sentry) + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: true) + allow(described_class).to receive(:logger).and_return(sentry_logger) + expect(described_class.send(:enabled?)).to be(true) + described_class.emit(raw_payload) + + expect_forwarded_payload + end + + it 'does not forward payloads when sentry logs are disabled' do + stub_const('Sentry', fake_sentry) + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: false) + described_class.emit(raw_payload) + + expect(captured_call).to eq({}) + end + + it 'does not forward payloads when sentry is disabled' do + stub_const('Sentry', fake_sentry) + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: false, sentry_logs_enabled?: true) + described_class.emit(raw_payload) + + expect(captured_call).to eq({}) + end + + it 'falls back to info when an unsupported level is requested', :aggregate_failures do + stub_const('Sentry', fake_sentry) + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: true) + allow(described_class).to receive(:logger).and_return(sentry_logger) + + described_class.emit(raw_payload.merge(level: :unknown_method)) + + expect(captured_call).to include(:message, :attributes) + expect(captured_call.fetch(:message)).to eq('auth.authenticate') + end + + def build_sentry_logger + logger_class.new(captured_call) + end + + def expect_forwarded_payload + expect(captured_call).to include(:message, :attributes) + expect_forwarded_message + expect_forwarded_attributes + end + + def expect_forwarded_message + expect(captured_call.fetch(:level)).to eq(:info) + expect(captured_call.fetch(:message)).to eq('auth.authenticate') + end + + def expect_forwarded_attributes + attributes = captured_call.fetch(:attributes) + expect(attributes).to include(event_name: 'auth.authenticate') + expect(attributes.fetch(:details)).to eq(reason: 'missing_token', nested: [{}]) + expect(attributes).not_to have_key(:actor) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4ee5b787..4f48479b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -47,12 +47,18 @@ RSpec.configure do |config| # Reset SecurityLogger before each test to avoid double leakage config.before do + ENV['HTML2RSS_SECRET_KEY'] ||= 'test-secret-key-for-specs' # Only reset if SecurityLogger is defined (loaded) Html2rss::Web::SecurityLogger.reset_logger! if defined?(Html2rss::Web::SecurityLogger) + Html2rss::Web::RuntimeEnv.reset! if defined?(Html2rss::Web::RuntimeEnv) Html2rss::Web::AccountManager.reload! if defined?(Html2rss::Web::AccountManager) Html2rss::Web::LocalConfig.reload! if defined?(Html2rss::Web::LocalConfig) end + config.after do + Html2rss::Web::RuntimeEnv.reset! if defined?(Html2rss::Web::RuntimeEnv) + end + # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest # assertions if you prefer. diff --git a/spec/support/runtime_env_helpers.rb b/spec/support/runtime_env_helpers.rb new file mode 100644 index 00000000..e8188718 --- /dev/null +++ b/spec/support/runtime_env_helpers.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RuntimeEnvHelpers + def capture_scrubbed_runtime_env(env_overrides) + ClimateControl.modify(preserved_runtime_env.merge(env_overrides)) do + Html2rss::Web::RuntimeEnv.reset! + Html2rss::Web::RuntimeEnv.capture! + yield + end + ensure + Html2rss::Web::RuntimeEnv.reset! + end + + private + + def preserved_runtime_env + %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN SENTRY_ENABLE_LOGS].each_with_object({}) do |key, env| + value = ENV.fetch(key, nil) + env[key] = value unless value.nil? + end + end +end + +RSpec.configure do |config| + config.include RuntimeEnvHelpers +end