diff --git a/.fixtures.yml b/.fixtures.yml index 68ef109e1b..845d4390e8 100644 --- a/.fixtures.yml +++ b/.fixtures.yml @@ -1,6 +1,7 @@ --- fixtures: repositories: + archive: "https://github.com/voxpupuli/puppet-archive.git" concat: "https://github.com/puppetlabs/puppetlabs-concat.git" facts: 'https://github.com/puppetlabs/puppetlabs-facts.git' portage: "https://github.com/gentoo/puppet-portage.git" diff --git a/manifests/mod/security.pp b/manifests/mod/security.pp index bbcf497f11..a039110926 100644 --- a/manifests/mod/security.pp +++ b/manifests/mod/security.pp @@ -8,8 +8,34 @@ # Configures the location of audit and debug logs. # # @param crs_package -# Name of package that installs CRS rules. -# +# Name of package that installs CRS rules. Only used when `crs_source` is `package`. +# +# @param crs_source +# How the OWASP Core Rule Set is obtained: +# * `package` - install `crs_package` and activate rules via per-rule symlinks (v2/v3 layout). Default on EL7/8/9. +# * `archive` - download the CRS v4 tarball via `puppet/archive` from `crs_archive_source` (e.g. an internal mirror) and wire the v4 includes. +# * `path` - use a pre-staged CRS v4 directory given by `crs_path` (no download); only wires the v4 includes. +# * `none` - engine only, no CRS managed. Default on EL10. +# +# @param crs_archive_source +# Source URL or path for the CRS v4 tarball when `crs_source` is `archive`. No module default +# (user-pinned, e.g. an internal mirror) to avoid a version/CVE maintenance treadmill. Required for `archive`. +# +# @param crs_archive_checksum +# Checksum of the CRS v4 tarball for verification when `crs_source` is `archive`. +# +# @param crs_archive_checksum_type +# Checksum algorithm for `crs_archive_checksum` (e.g. `sha256`). +# +# @param crs_version +# The pinned CRS version (e.g. `4.27.0`), required for `crs_source => archive`. It fixes the +# extracted `coreruleset-` directory name so the include paths are deterministic. +# +# @param crs_path +# For `crs_source => path`: absolute path to the pre-staged CRS v4 directory (contains +# `crs-setup.conf` and `rules/`). For `crs_source => archive`: overrides the extraction base +# directory (default `/usr/share`); the ruleset then lives at `/coreruleset-`. +# # @param activated_rules # An array of rules from the modsec_crs_path or absolute to activate via symlinks. # @@ -139,12 +165,21 @@ # # @note On RHEL/EL 10 the ModSecurity engine is provided by EPEL (enable EPEL # yourself; this module does not manage it). The OWASP CRS package -# (`mod_security_crs`) is not available on EL10, so the class manages the -# engine only there and does not install or activate CRS rules. +# (`mod_security_crs`) is not available on EL10, so `crs_source` defaults to +# `none` (engine only). CRS v4 can be opted into there via `crs_source => +# 'archive'` (downloaded from `crs_archive_source`, e.g. an internal mirror) +# or `crs_source => 'path'` (a pre-staged directory). EL7/8/9 keep the +# package-based default unchanged. class apache::mod::security ( Stdlib::Absolutepath $logroot = $apache::params::logroot, Integer $version = $apache::params::modsec_version, Optional[String] $crs_package = $apache::params::modsec_crs_package, + Enum['package', 'archive', 'path', 'none'] $crs_source = $apache::params::modsec_crs_source, + Optional[String[1]] $crs_archive_source = $apache::params::modsec_crs_archive_source, + Optional[String[1]] $crs_archive_checksum = $apache::params::modsec_crs_archive_checksum, + String[1] $crs_archive_checksum_type = $apache::params::modsec_crs_archive_checksum_type, + Optional[String[1]] $crs_version = undef, + Optional[Stdlib::Absolutepath] $crs_path = undef, Array[String] $activated_rules = $apache::params::modsec_default_rules, Boolean $custom_rules = $apache::params::modsec_custom_rules, Optional[Array[String]] $custom_rules_set = $apache::params::modsec_custom_rules_set, @@ -227,14 +262,82 @@ lib => 'mod_unique_id.so', } - if $crs_package { - package { $crs_package: - ensure => 'installed', - before => [ - File[$apache::confd_dir], - File[$modsec_dir], - ], + # Effective on-disk CRS v4 directory used in the include wiring. Kept outside + # $modsec_dir, which is managed with purge => true and would otherwise remove + # the extracted rule tree. + # - path: $crs_path is the ready CRS directory (contains crs-setup.conf + rules/). + # - archive: CRS tarballs unpack to a versioned dir, so the ruleset lives at + # /coreruleset- under the extraction base. + $_crs_extract_base = $crs_path ? { + undef => '/usr/share', + default => $crs_path, + } + $_crs_dir = $crs_source ? { + 'archive' => "${_crs_extract_base}/coreruleset-${crs_version}", + default => $crs_path, + } + + # CRS acquisition. The activation wiring is selected later by the same + # $crs_source: `package` keeps the legacy per-rule symlinks (v2/v3 layout), + # while `archive`/`path` use the v4 include layout. + case $crs_source { + 'package': { + if $crs_package { + package { $crs_package: + ensure => 'installed', + before => [ + File[$apache::confd_dir], + File[$modsec_dir], + ], + } + } } + 'archive': { + if ! $crs_archive_source { + fail('apache::mod::security: crs_source => "archive" requires crs_archive_source (URL/path to the CRS v4 tarball, e.g. an internal mirror).') + } + if ! $crs_version { + fail('apache::mod::security: crs_source => "archive" requires crs_version (the pinned CRS version, e.g. "4.27.0"); it fixes the extracted coreruleset- directory name.') + } + + file { $_crs_extract_base: + ensure => directory, + } + + # Both the release "-minimal" asset and the source archive unpack to a + # versioned top-level dir, coreruleset-/. Checksum + # verification is only enabled when a checksum is supplied (a trusted + # internal mirror may legitimately be used without one). + archive { 'coreruleset.tar.gz': + ensure => present, + path => '/var/cache/coreruleset.tar.gz', + source => $crs_archive_source, + checksum => $crs_archive_checksum, + checksum_type => $crs_archive_checksum_type, + checksum_verify => $crs_archive_checksum =~ NotUndef, + extract => true, + extract_path => $_crs_extract_base, + creates => "${_crs_dir}/crs-setup.conf.example", + cleanup => true, + require => File[$_crs_extract_base], + } + + # CRS ships crs-setup.conf.example; create the active crs-setup.conf from + # it once. The creates guard prevents clobbering later user edits. + exec { 'apache-crs-setup-conf': + command => "/bin/cp ${_crs_dir}/crs-setup.conf.example ${_crs_dir}/crs-setup.conf", + creates => "${_crs_dir}/crs-setup.conf", + require => Archive['coreruleset.tar.gz'], + notify => Class['apache::service'], + } + } + 'path': { + if ! $crs_path { + fail('apache::mod::security: crs_source => "path" requires crs_path (absolute path to a pre-staged CRS v4 directory).') + } + } + 'none': {} + default: {} } # Template uses: @@ -329,7 +432,9 @@ } } - if $manage_security_crs { + if $manage_security_crs and $crs_source == 'package' { + # Legacy CRS v2/v3 layout: a tuning conf plus per-rule symlinks under + # activated_rules/. Unchanged behaviour for EL7/8/9 package installs. # Template uses: # - $_secdefaultaction # - $critical_anomaly_score @@ -381,4 +486,16 @@ apache::security::rule_link { $activated_rules: } } } + + if $manage_security_crs and $crs_source in ['archive', 'path'] { + # CRS v4 layout: load crs-setup.conf then rules/*.conf from the CRS + # directory. Dropped into $modsec_dir so the existing + # `IncludeOptional ${modsec_dir}/*.conf` in security.conf picks it up. + file { "${modsec_dir}/security_crs_v4.conf": + ensure => file, + content => epp('apache/mod/security_crs_v4.conf.epp', { 'crs_dir' => $_crs_dir }), + require => File[$modsec_dir], + notify => Class['apache::service'], + } + } } diff --git a/manifests/params.pp b/manifests/params.pp index 48763db608..cfa28d0340 100644 --- a/manifests/params.pp +++ b/manifests/params.pp @@ -806,4 +806,19 @@ $ssl_cipher = 'HIGH:MEDIUM:!aNULL:!MD5:!RC4:!3DES' $ssl_proxy_cipher_suite = undef } + + # OWASP CRS acquisition mode (see apache::mod::security). + # EL10 has no mod_security_crs package, so default to engine-only there + # (CRS is opt-in via the archive/path modes). Every other platform keeps + # the existing package-based behaviour, so nothing changes for them. + if $facts['os']['family'] == 'RedHat' and versioncmp($facts['os']['release']['major'], '10') >= 0 { + $modsec_crs_source = 'none' + } else { + $modsec_crs_source = 'package' + } + # No module-shipped default version/URL: the archive source is user-pinned + # (e.g. an internal mirror) to keep us off the CRS version/CVE treadmill. + $modsec_crs_archive_source = undef + $modsec_crs_archive_checksum = undef + $modsec_crs_archive_checksum_type = 'sha256' } diff --git a/metadata.json b/metadata.json index 126f058449..07f4e68ea5 100644 --- a/metadata.json +++ b/metadata.json @@ -15,6 +15,10 @@ { "name": "puppetlabs/concat", "version_requirement": ">= 2.2.1 < 11.0.0" + }, + { + "name": "puppet/archive", + "version_requirement": ">= 4.0.0 < 9.0.0" } ], "operatingsystem_support": [ diff --git a/spec/acceptance/mod_security_crs_v4_spec.rb b/spec/acceptance/mod_security_crs_v4_spec.rb new file mode 100644 index 0000000000..d72844c412 --- /dev/null +++ b/spec/acceptance/mod_security_crs_v4_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper_acceptance' + +# CRS v4 support (MODULES-11857). On modern Enterprise Linux (EL10) there is no +# mod_security_crs package, so CRS is deployed via crs_source => path (pre-staged +# directory) or archive (fetched from a configurable, e.g. internal-mirror, URL). +# A minimal self-contained CRS fixture is built on the target so the test needs +# no internet access for the rule content. +describe 'apache::mod::security CRS v4', if: (os[:family].include?('redhat') && os[:release].to_i >= 10) do + before(:all) do + # The module does not manage the ModSecurity engine on EL10; provide it from EPEL. + run_shell('dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm || true') + run_shell('dnf install -y mod_security') + + # Minimal but valid CRS v4 layout for crs_source => path. + run_shell('mkdir -p /opt/crs/rules') + run_shell(%q(printf 'SecDefaultAction "phase:1,pass,log"\n' > /opt/crs/crs-setup.conf)) + run_shell(%q(printf 'SecRule ARGS "@rx evilpattern" "id:9001,phase:2,deny,status:403,log"\n' > /opt/crs/rules/REQUEST-901-TEST.conf)) + + # A tarball standing in for an internal mirror, unpacking to the versioned + # coreruleset-/ dir with crs-setup.conf.example, as upstream ships. + run_shell('mkdir -p /tmp/crsbuild/coreruleset-9.9.9/rules /var/www/mirror') + run_shell('cp /opt/crs/crs-setup.conf /tmp/crsbuild/coreruleset-9.9.9/crs-setup.conf.example') + run_shell('cp /opt/crs/rules/REQUEST-901-TEST.conf /tmp/crsbuild/coreruleset-9.9.9/rules/') + run_shell('tar -C /tmp/crsbuild -czf /var/www/mirror/crs.tar.gz coreruleset-9.9.9') + end + + context 'crs_source => path' do + pp = <<-MANIFEST + class { 'apache': } + class { 'apache::mod::security': + crs_source => 'path', + crs_path => '/opt/crs', + } + MANIFEST + + it 'applies idempotently' do + idempotent_apply(pp) + end + + describe file('/etc/httpd/modsecurity.d/security_crs_v4.conf') do + it { is_expected.to be_file } + its(:content) { is_expected.to match(%r{IncludeOptional /opt/crs/crs-setup\.conf}) } + its(:content) { is_expected.to match(%r{IncludeOptional /opt/crs/rules/\*\.conf}) } + end + + describe command('apachectl configtest') do + its(:exit_status) { is_expected.to eq 0 } + end + end + + context 'crs_source => archive' do + pp = <<-MANIFEST + class { 'apache': } + class { 'apache::mod::security': + crs_source => 'archive', + crs_archive_source => 'file:///var/www/mirror/crs.tar.gz', + crs_version => '9.9.9', + } + MANIFEST + + it 'applies idempotently' do + idempotent_apply(pp) + end + + # Extracted to the versioned dir and crs-setup.conf created from the example. + describe file('/usr/share/coreruleset-9.9.9/crs-setup.conf') do + it { is_expected.to be_file } + end + + describe file('/etc/httpd/modsecurity.d/security_crs_v4.conf') do + its(:content) { is_expected.to match(%r{IncludeOptional /usr/share/coreruleset-9\.9\.9/crs-setup\.conf}) } + its(:content) { is_expected.to match(%r{IncludeOptional /usr/share/coreruleset-9\.9\.9/rules/\*\.conf}) } + end + + describe command('apachectl configtest') do + its(:exit_status) { is_expected.to eq 0 } + end + end +end diff --git a/spec/classes/mod/security_spec.rb b/spec/classes/mod/security_spec.rb index b9ff1a8de9..b4ce79e7dd 100644 --- a/spec/classes/mod/security_spec.rb +++ b/spec/classes/mod/security_spec.rb @@ -438,4 +438,111 @@ ) } end + + # CRS v4 acquisition modes (MODULES-11857). Exercised on EL10, where the + # default is engine-only and CRS is opt-in via archive/path. + context 'crs_source modes on RedHat 10' do + let :facts do + { + os: { + 'architecture' => 'x86_64', + 'family' => 'RedHat', + 'hardware' => 'x86_64', + 'name' => 'RedHat', + 'release' => { 'full' => '10.0', 'major' => '10' }, + 'selinux' => { 'enabled' => false }, + }, + } + end + + context 'default (none)' do + it { is_expected.to compile.with_all_deps } + it { is_expected.not_to contain_archive('coreruleset.tar.gz') } + it { is_expected.not_to contain_file('/etc/httpd/modsecurity.d/security_crs_v4.conf') } + end + + context "crs_source => 'archive'" do + context 'without crs_archive_source' do + let(:params) { { crs_source: 'archive', crs_version: '4.27.0' } } + + it { is_expected.to compile.and_raise_error(%r{crs_archive_source}) } + end + + context 'without crs_version' do + let(:params) do + { crs_source: 'archive', crs_archive_source: 'https://mirror.internal.example/crs.tar.gz' } + end + + it { is_expected.to compile.and_raise_error(%r{crs_version}) } + end + + context 'with crs_archive_source and crs_version' do + let(:params) do + { + crs_source: 'archive', + crs_archive_source: 'https://mirror.internal.example/crs/coreruleset-4.27.0-minimal.tar.gz', + crs_archive_checksum: 'abc123', + crs_version: '4.27.0', + } + end + + it { is_expected.to compile.with_all_deps } + + it { + expect(subject).to contain_archive('coreruleset.tar.gz').with( + source: 'https://mirror.internal.example/crs/coreruleset-4.27.0-minimal.tar.gz', + checksum: 'abc123', + checksum_type: 'sha256', + checksum_verify: true, + extract: true, + extract_path: '/usr/share', + creates: '/usr/share/coreruleset-4.27.0/crs-setup.conf.example', + ) + } + end + + context 'with crs_archive_source but no checksum (trusted mirror)' do + let(:params) do + { + crs_source: 'archive', + crs_archive_source: 'https://mirror.internal.example/crs/coreruleset-4.27.0-minimal.tar.gz', + crs_version: '4.27.0', + } + end + + it { is_expected.to compile.with_all_deps } + # No checksum supplied -> verification is disabled (avoids empty-checksum mismatch). + it { is_expected.to contain_archive('coreruleset.tar.gz').with_checksum_verify(false) } + + it { is_expected.to contain_exec('apache-crs-setup-conf').with_creates('/usr/share/coreruleset-4.27.0/crs-setup.conf') } + + it { + expect(subject).to contain_file('/etc/httpd/modsecurity.d/security_crs_v4.conf') + .with_content(%r{IncludeOptional /usr/share/coreruleset-4\.27\.0/crs-setup\.conf}) + .with_content(%r{IncludeOptional /usr/share/coreruleset-4\.27\.0/rules/\*\.conf}) + } + end + end + + context "crs_source => 'path'" do + context 'without crs_path' do + let(:params) { { crs_source: 'path' } } + + it { is_expected.to compile.and_raise_error(%r{crs_path}) } + end + + context 'with crs_path' do + let(:params) { { crs_source: 'path', crs_path: '/opt/crs' } } + + it { is_expected.to compile.with_all_deps } + it { is_expected.not_to contain_archive('coreruleset.tar.gz') } + + it { + expect(subject).to contain_file('/etc/httpd/modsecurity.d/security_crs_v4.conf') + .with_content(%r{IncludeOptional /opt/crs/crs-setup\.conf}) + .with_content(%r{IncludeOptional /opt/crs/rules/\*\.conf}) + } + end + end + end end diff --git a/templates/mod/security_crs_v4.conf.epp b/templates/mod/security_crs_v4.conf.epp new file mode 100644 index 0000000000..15ea9a7075 --- /dev/null +++ b/templates/mod/security_crs_v4.conf.epp @@ -0,0 +1,9 @@ +<%- | Stdlib::Absolutepath $crs_dir | -%> +# Managed by Puppet (apache::mod::security, crs_source => archive|path). +# +# OWASP Core Rule Set v4 on-disk layout. Unlike the v2/v3 package model +# (per-rule symlinks under activated_rules/), v4 is loaded as a self-contained +# directory: the setup file first, then every rule file in filename order +# (CRS numbers its files so the wildcard include resolves the correct order). +IncludeOptional <%= $crs_dir %>/crs-setup.conf +IncludeOptional <%= $crs_dir %>/rules/*.conf