diff --git a/Gemfile b/Gemfile index b5e4a07..4903612 100644 --- a/Gemfile +++ b/Gemfile @@ -8,5 +8,6 @@ gem "activestorage" gem "rake" gem "rspec" gem "rubocop-katalyst" +gem "sentry-rails" gem "solid_cable" gem "solid_queue" diff --git a/Gemfile.lock b/Gemfile.lock index cdeb1c4..6bb5343 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - katalyst-thermite (1.0.0) + katalyst-thermite (1.1.0) activesupport railties @@ -226,6 +226,13 @@ GEM rubocop-rspec (~> 3.5) ruby-progressbar (1.13.0) securerandom (0.4.1) + sentry-rails (7.0.0) + railties (>= 5.2.0) + sentry-ruby (~> 7.0.0) + sentry-ruby (7.0.0) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + logger solid_cable (4.0.2) actioncable (>= 7.2) activejob (>= 7.2) @@ -270,6 +277,7 @@ DEPENDENCIES rake rspec rubocop-katalyst + sentry-rails solid_cable solid_queue @@ -301,7 +309,7 @@ CHECKSUMS io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a - katalyst-thermite (1.0.0) + katalyst-thermite (1.1.0) language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 @@ -354,6 +362,8 @@ CHECKSUMS rubocop-rspec_rails (2.32.0) sha256=4a0d641c72f6ebb957534f539d9d0a62c47abd8ce0d0aeee1ef4701e892a9100 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + sentry-rails (7.0.0) sha256=6ac6a010e088632e46710ce42db75aa5281a6b52a547efee10568cc2ed80605a + sentry-ruby (7.0.0) sha256=e9616ff521355a983fad404ca575d8bb1f1949a8cac42a7a893dd02156bcdb1a solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 diff --git a/README.md b/README.md index e604246..7fa9fe7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,10 @@ Available installers: ```shell bin/rails thermite:install:active_storage +bin/rails thermite:install:content_security_policy bin/rails thermite:install:docker +bin/rails thermite:install:permissions_policy +bin/rails thermite:install:sentry bin/rails thermite:install:solid_cable bin/rails thermite:install:solid_queue ``` diff --git a/katalyst-thermite.gemspec b/katalyst-thermite.gemspec index d823b85..1be1a8b 100644 --- a/katalyst-thermite.gemspec +++ b/katalyst-thermite.gemspec @@ -2,7 +2,7 @@ Gem::Specification.new do |spec| spec.name = "katalyst-thermite" - spec.version = "1.0.0" + spec.version = "1.1.0" spec.authors = ["Katalyst Interactive"] spec.email = ["developers@katalyst.com.au"] diff --git a/lib/generators/thermite/install/content_security_policy/USAGE b/lib/generators/thermite/install/content_security_policy/USAGE new file mode 100644 index 0000000..70ed4cf --- /dev/null +++ b/lib/generators/thermite/install/content_security_policy/USAGE @@ -0,0 +1,21 @@ +Description: + Installs the Katalyst content security policy configuration. + + Generates config/initializers/content_security_policy.rb with a + nonce-based policy controlled per deployment via CONTENT_SECURITY_POLICY: + + - enforce (default): the policy is enforced + - report: policy violations are reported but not enforced — use this to + trial policy changes and review violations in Sentry before enforcing + - none: no policy header is sent — set this on existing apps that have + not yet reviewed the policy against their content + + Violation reports are delivered to Sentry when SENTRY_DSN is set and the + Sentry initializer registers the report URI (see thermite:install:sentry). + + See https://docs.sentry.io/platforms/ruby/guides/rails/security-policy-reporting/ + and the project's Sentry Project Settings > SDK Setup > Security Headers page + (https://.sentry.io/settings/projects//security-headers/). + +Example: + bin/rails generate thermite:install:content_security_policy diff --git a/lib/generators/thermite/install/content_security_policy/content_security_policy_generator.rb b/lib/generators/thermite/install/content_security_policy/content_security_policy_generator.rb new file mode 100644 index 0000000..5c153cb --- /dev/null +++ b/lib/generators/thermite/install/content_security_policy/content_security_policy_generator.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails" +require "rails/generators" + +module Thermite + module Install + class ContentSecurityPolicyGenerator < Rails::Generators::Base + def self.source_root + File.expand_path("templates", __dir__) + end + + def copy_files + copy_file "config/initializers/content_security_policy.rb" + end + end + end +end diff --git a/lib/generators/thermite/install/content_security_policy/templates/config/initializers/content_security_policy.rb b/lib/generators/thermite/install/content_security_policy/templates/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..7397748 --- /dev/null +++ b/lib/generators/thermite/install/content_security_policy/templates/config/initializers/content_security_policy.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Enforced by default. Set CONTENT_SECURITY_POLICY=report per deployment to +# trial policy changes and review violations in Sentry, or =none for existing +# apps that have not yet reviewed the policy against their content. +CSP_MODE = ENV.fetch("CONTENT_SECURITY_POLICY", "enforce").freeze + +warn "Unknown configuration CONTENT_SECURITY_POLICY=#{CSP_MODE}" unless CSP_MODE.in?(%w[none report enforce]) + +return if CSP_MODE == "none" + +# Deliver violation reports to Sentry (see config/initializers/sentry.rb) +# https://docs.sentry.io/platforms/ruby/guides/rails/security-policy-reporting/ +# Sentry's report endpoint is shown under Project Settings > SDK Setup > Security Headers, +# e.g. https://.sentry.io/settings/projects//security-headers/ +reporting = if ENV.key?("SENTRY_DSN") + sentry_uri = "https://#{URI(ENV.fetch('SENTRY_DSN')).hostname}" + [sentry_uri, sentry_uri.gsub("ingest.sentry", "ingest.us.sentry")] + else + [] + end + +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src(:self) + policy.frame_ancestors(:self) + + # Permissive policy, many browser plugins use custom fonts + policy.font_src(:self, :data, :https) + + # Permissive policy, allow data URLs and https (hotlinking, tracking pixels) + policy.img_src(:self, :data, :https) + + # Block legacy embeds, such as flash + policy.object_src(:none) + + # Restricted policy, all scripts should have nonce or SRI. strict-dynamic + # allows scripts to load children (CSP-3); unsafe-inline and https are + # fallbacks for legacy browsers, ignored when nonces are supported. + policy.script_src(:self, :strict_dynamic, :wasm_unsafe_eval, :unsafe_inline, :https) + + # Permissive policy, browser plugins inject inline styles. + # Safari < 26.0 does not support -attr and -elem (https://bugs.webkit.org/show_bug.cgi?id=276931) + policy.style_src(:self, :unsafe_inline, :https) + + # Permissive policy, allows external embeds and user browser plugins + policy.frame_src(:self, :https) + + # Permissive policy, many browser plugins use connect + policy.connect_src(:self, :data, :https, *reporting) + end + + # Generate nonces for permitted importmap, inline scripts, and inline styles. + # A fresh nonce per request is incompatible with conditional GET caching; apps + # that create a session on every request can use the session id instead, see + # https://guides.rubyonrails.org/security.html#adding-a-nonce + config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) } + config.content_security_policy_nonce_directives = %w[script-src style-src-elem] + + # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` + # if the corresponding directives are specified in `content_security_policy_nonce_directives`. + config.content_security_policy_nonce_auto = true + + # Report violations without enforcing the policy. + config.content_security_policy_report_only = true if CSP_MODE == "report" +end diff --git a/lib/generators/thermite/install/permissions_policy/USAGE b/lib/generators/thermite/install/permissions_policy/USAGE new file mode 100644 index 0000000..e3493ec --- /dev/null +++ b/lib/generators/thermite/install/permissions_policy/USAGE @@ -0,0 +1,11 @@ +Description: + Installs the Katalyst permissions policy configuration. + + Generates config/initializers/permissions_policy.rb with a restrictive + Permissions-Policy header: camera, microphone, geolocation, payment, + gyroscope, and usb are denied, and fullscreen is limited to the app + itself. Allow specific features (e.g. geolocation, payment) only for apps + that use them. The header applies as soon as it is deployed. + +Example: + bin/rails generate thermite:install:permissions_policy diff --git a/lib/generators/thermite/install/permissions_policy/permissions_policy_generator.rb b/lib/generators/thermite/install/permissions_policy/permissions_policy_generator.rb new file mode 100644 index 0000000..6441ce1 --- /dev/null +++ b/lib/generators/thermite/install/permissions_policy/permissions_policy_generator.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails" +require "rails/generators" + +module Thermite + module Install + class PermissionsPolicyGenerator < Rails::Generators::Base + def self.source_root + File.expand_path("templates", __dir__) + end + + def copy_files + copy_file "config/initializers/permissions_policy.rb" + end + end + end +end diff --git a/lib/generators/thermite/install/permissions_policy/templates/config/initializers/permissions_policy.rb b/lib/generators/thermite/install/permissions_policy/templates/config/initializers/permissions_policy.rb new file mode 100644 index 0000000..0dbc64b --- /dev/null +++ b/lib/generators/thermite/install/permissions_policy/templates/config/initializers/permissions_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +Rails.application.config.permissions_policy do |policy| + policy.camera :none + policy.fullscreen :self + policy.geolocation :none + policy.gyroscope :none + policy.microphone :none + policy.payment :none + policy.usb :none +end diff --git a/lib/generators/thermite/install/sentry/USAGE b/lib/generators/thermite/install/sentry/USAGE new file mode 100644 index 0000000..afdfe24 --- /dev/null +++ b/lib/generators/thermite/install/sentry/USAGE @@ -0,0 +1,18 @@ +Description: + Installs the Katalyst Sentry configuration. + + Generates config/initializers/sentry.rb with the standard errors-only + data posture for sentry-ruby/sentry-rails 7.x: + + - structured logging disabled (no request/SQL logs sent to Sentry) + - explicit data_collection settings (no user info, cookies, bodies, + query params, or database query values) + - error events sanitized with the app's parameter filter + - CSP violations reported to Sentry when a content security policy is + configured (see thermite:install:content_security_policy and + https://docs.sentry.io/platforms/ruby/guides/rails/security-policy-reporting/) + - tracing and profiling off unless enabled per deployment via + SENTRY_TRACES_SAMPLE_RATE / SENTRY_PROFILES_SAMPLE_RATE + +Example: + bin/rails generate thermite:install:sentry diff --git a/lib/generators/thermite/install/sentry/sentry_generator.rb b/lib/generators/thermite/install/sentry/sentry_generator.rb new file mode 100644 index 0000000..7931ab6 --- /dev/null +++ b/lib/generators/thermite/install/sentry/sentry_generator.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails" +require "rails/generators" + +module Thermite + module Install + class SentryGenerator < Rails::Generators::Base + def self.source_root + File.expand_path("templates", __dir__) + end + + # Fail early with a helpful message when sentry-rails isn't available. + def verify_sentry! + require "sentry-rails" + rescue LoadError + raise Thor::Error, <<~MSG.strip + thermite:install:sentry requires the sentry-rails gem, which is not available. + Add it to your Gemfile and run `bundle install`: + + gem "sentry-rails" + MSG + end + + def copy_files + template "config/initializers/sentry.rb" + end + + private + + # The environments Sentry should report from: every deployed environment, + # derived from config/environments/*.rb so we don't hard-code names like + # "staging" / "uat". + def enabled_environments + environments + .map { |pathname| pathname.basename(".rb").to_s } + .reject { |environment| %w[development test].include?(environment) } + .sort_by { |environment| environment == "production" ? 1 : 0 } + end + + def environments + Pathname(destination_root).join("config/environments").glob("*.rb") + end + end + end +end diff --git a/lib/generators/thermite/install/sentry/templates/config/initializers/sentry.rb.tt b/lib/generators/thermite/install/sentry/templates/config/initializers/sentry.rb.tt new file mode 100644 index 0000000..179788e --- /dev/null +++ b/lib/generators/thermite/install/sentry/templates/config/initializers/sentry.rb.tt @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Sanitize error events with the application's parameter filter +filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters) +sanitize = lambda do |event, _hint| + event.extra = filter.filter(event.extra) if event.extra + event.user = filter.filter(event.user) if event.user + event.contexts = filter.filter(event.contexts) if event.contexts + event +end + +ActiveSupport.on_load(:active_record) do + version = Rails.root.join("VERSION") + + # DSN is set via SENTRY_DSN in ENV + Sentry.init do |config| + config.enabled_environments = %w[<%= enabled_environments.join(" ") %>] + config.release = File.read(version).strip.split("/").last if version.exist? + + # Errors only: structured logging sends a log event for every request and + # SQL query. Collecting logs is a per-client data decision. + config.rails.structured_logging.enabled = false + + # Data collection posture, based on sentry-ruby 7's `send_default_pii` + pii_headers = %w[forwarded -ip _ip remote via _user -user] + + config.data_collection.user_info = false + config.data_collection.cookies.mode = :off + config.data_collection.http_headers.request.terms = pii_headers + config.data_collection.http_headers.response.terms = pii_headers + config.data_collection.http_bodies = [] + config.data_collection.url_query_params.mode = :off + config.data_collection.database_query_data = false + config.data_collection.queues = false + + config.before_send = sanitize + + # Report CSP violations to Sentry (requires a content security policy) + # https://docs.sentry.io/platforms/ruby/guides/rails/security-policy-reporting/ + if (policy = Rails.application.config.content_security_policy).present? && config.csp_report_uri.present? + policy.report_uri(config.csp_report_uri) + end + + # Tracing and profiling, off unless enabled per deployment. + # Profiling requires the vernier gem. + config.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", "0").to_f + config.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", "0").to_f + config.profiler_class = Sentry::Vernier::Profiler + end +end diff --git a/spec/generators/thermite/install/content_security_policy_generator_spec.rb b/spec/generators/thermite/install/content_security_policy_generator_spec.rb new file mode 100644 index 0000000..0772b16 --- /dev/null +++ b/spec/generators/thermite/install/content_security_policy_generator_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +require "prism" + +require "generators/thermite/install/content_security_policy/content_security_policy_generator" + +RSpec.describe Thermite::Install::ContentSecurityPolicyGenerator do + it "completes successfully" do + expect { run_generator }.not_to raise_error + end + + it "writes a syntactically valid initializer" do + run_generator + + result = Prism.parse_file(File.join(destination_root, "config/initializers/content_security_policy.rb")) + expect(result.errors).to be_empty + end + + it "enforces the policy unless overridden per deployment" do + run_generator + + assert_file "config/initializers/content_security_policy.rb", + /ENV\.fetch\("CONTENT_SECURITY_POLICY", "enforce"\)/ + end + + it "allows CSP reports to be delivered to Sentry" do + run_generator + + assert_file "config/initializers/content_security_policy.rb", /SENTRY_DSN/ + end + + it "configures nonces for scripts and styles" do + run_generator + + assert_file "config/initializers/content_security_policy.rb", + /content_security_policy_nonce_directives = %w\[script-src style-src-elem\]/ + end +end diff --git a/spec/generators/thermite/install/permissions_policy_generator_spec.rb b/spec/generators/thermite/install/permissions_policy_generator_spec.rb new file mode 100644 index 0000000..339886b --- /dev/null +++ b/spec/generators/thermite/install/permissions_policy_generator_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +require "prism" + +require "generators/thermite/install/permissions_policy/permissions_policy_generator" + +RSpec.describe Thermite::Install::PermissionsPolicyGenerator do + it "completes successfully" do + expect { run_generator }.not_to raise_error + end + + it "writes a syntactically valid initializer" do + run_generator + + result = Prism.parse_file(File.join(destination_root, "config/initializers/permissions_policy.rb")) + expect(result.errors).to be_empty + end + + it "restricts sensitive device features" do + run_generator + + assert_file "config/initializers/permissions_policy.rb", /policy\.camera\s+:none/ + assert_file "config/initializers/permissions_policy.rb", /policy\.microphone\s+:none/ + assert_file "config/initializers/permissions_policy.rb", /policy\.geolocation\s+:none/ + assert_file "config/initializers/permissions_policy.rb", /policy\.payment\s+:none/ + end +end diff --git a/spec/generators/thermite/install/sentry_generator_spec.rb b/spec/generators/thermite/install/sentry_generator_spec.rb new file mode 100644 index 0000000..c2c3abc --- /dev/null +++ b/spec/generators/thermite/install/sentry_generator_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "rails_helper" + +require "prism" + +require "generators/thermite/install/sentry/sentry_generator" + +RSpec.describe Thermite::Install::SentryGenerator do + it "completes successfully" do + expect { run_generator }.not_to raise_error + end + + it "writes a syntactically valid initializer" do + run_generator + + result = Prism.parse_file(File.join(destination_root, "config/initializers/sentry.rb")) + expect(result.errors).to be_empty + end + + it "derives enabled environments from the app" do + run_generator + + assert_file "config/initializers/sentry.rb", /enabled_environments = %w\[staging production\]/ + end + + it "disables structured logging" do + run_generator + + assert_file "config/initializers/sentry.rb", /config\.rails\.structured_logging\.enabled = false/ + end + + it "pins the errors-only data collection posture" do + run_generator + + assert_file "config/initializers/sentry.rb", /data_collection\.user_info\s+= false/ + assert_file "config/initializers/sentry.rb", /data_collection\.http_bodies\s+= \[\]/ + assert_file "config/initializers/sentry.rb", /data_collection\.url_query_params\.mode\s+= :off/ + assert_file "config/initializers/sentry.rb", /data_collection\.database_query_data\s+= false/ + end + + it "reports CSP violations to Sentry when a policy is configured" do + run_generator + + assert_file "config/initializers/sentry.rb", /policy\.report_uri\(config\.csp_report_uri\)/ + end + + it "raises a helpful error when sentry-rails is unavailable" do + allow(generator).to receive(:require).and_raise(LoadError) + + expect { generator.verify_sentry! } + .to raise_error(Thor::Error, /requires the sentry-rails gem/) + end +end