From c6101be78ef47fcea791245dbc555930e6b2121f Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 27 Jan 2026 20:17:22 -0500 Subject: [PATCH 001/428] fix: Make SSL configurable for Docker deployments (#700, #702) - Set assume_ssl=false (don't blindly assume SSL termination) - Make force_ssl ENV-configurable via RAILS_FORCE_SSL (default: true) - Add documentation for Docker quickstart without SSL - Add TDD tests for SSL configuration Fixes #700 (infinite redirect loop) Fixes #702 (Puma HTTP parse error) Authored by: Aaron Lippold --- .env.example | 11 +++++ .env.production.example | 7 +++- ENVIRONMENT_VARIABLES.md | 2 +- config/environments/production.rb | 8 ++-- docs/deployment/docker.md | 8 +++- docs/getting-started/environment-variables.md | 2 +- spec/config/ssl_configuration_spec.rb | 42 +++++++++++++++++++ 7 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 spec/config/ssl_configuration_spec.rb diff --git a/.env.example b/.env.example index aac2a9e31..36002b13e 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,17 @@ SECRET_KEY_BASE=development_secret_key_base_not_for_production_use CIPHER_PASSWORD=development_cipher_password_not_for_production_use CIPHER_SALT=development_cipher_salt_not_for_production_use +# ============================================================================= +# SSL/TLS CONFIGURATION +# ============================================================================= +# Force HTTPS redirects. Defaults to true (secure by default). +# Set to false ONLY for Docker quickstart without SSL termination or local testing. +# When behind a reverse proxy (nginx, traefik), the proxy handles SSL termination +# and sets X-Forwarded-Proto header, so keep this true. +# RAILS_FORCE_SSL=true +# For local Docker testing without SSL: +# RAILS_FORCE_SSL=false + # ============================================================================= # TEST OKTA CONFIGURATION (Development/Testing) # ============================================================================= diff --git a/.env.production.example b/.env.production.example index 4f26c372f..6b3ac78ee 100644 --- a/.env.production.example +++ b/.env.production.example @@ -95,8 +95,11 @@ STRUCTURED_LOGGING=true RAILS_MAX_THREADS=5 WEB_CONCURRENCY=2 -# Force SSL in production -FORCE_SSL=true +# Force HTTPS redirects. Defaults to true (secure by default). +# Set to false ONLY for Docker quickstart without SSL termination. +# When behind a reverse proxy (nginx, traefik), keep this true as the +# proxy handles SSL termination and sets X-Forwarded-Proto header. +RAILS_FORCE_SSL=true # Serve static files (required for Docker) RAILS_SERVE_STATIC_FILES=true diff --git a/ENVIRONMENT_VARIABLES.md b/ENVIRONMENT_VARIABLES.md index 6eb42d0cb..ae1cc6b15 100644 --- a/ENVIRONMENT_VARIABLES.md +++ b/ENVIRONMENT_VARIABLES.md @@ -204,7 +204,7 @@ This ensures OIDC auto-discovery events and all application logs are visible in | `RAILS_MASTER_KEY` | Rails master key for credentials | - | Generated by Rails | | `RAILS_LOG_TO_STDOUT` | Log to stdout instead of files | - | `true` | | `RAILS_SERVE_STATIC_FILES` | Serve static files in production | - | `true` | -| `FORCE_SSL` | Force SSL connections | - | `true` | +| `RAILS_FORCE_SSL` | Force HTTPS redirects (set to `false` for Docker without SSL termination) | `true` | `false` | ## Container Logging (Production) diff --git a/config/environments/production.rb b/config/environments/production.rb index 0d647bba8..f21e4f38c 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -26,11 +26,13 @@ # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local - # Assume all access to the app is happening through a SSL-terminating reverse proxy. - config.assume_ssl = true + # Don't blindly assume SSL - the reverse proxy should set X-Forwarded-Proto header. + # Setting this to true causes issues when accessing the app directly (e.g., Docker quickstart). + config.assume_ssl = false # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true + # Secure by default. Set RAILS_FORCE_SSL=false for local Docker testing without SSL. + config.force_ssl = ENV.fetch("RAILS_FORCE_SSL", "true").downcase != "false" # Skip http-to-https redirect for the default health check endpoint. # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 1b7ab9f8a..6a7b312ac 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -21,6 +21,8 @@ chmod +x setup-docker-secrets.sh docker-compose up -d ``` +> **Note**: For local testing without SSL/reverse proxy, add `RAILS_FORCE_SSL=false` to your `.env` file to prevent redirect loops. For production with SSL termination (nginx, traefik), keep the default `RAILS_FORCE_SSL=true`. + ### Using Docker Run ```bash @@ -93,7 +95,11 @@ VULCAN_LDAP_BASE=dc=example,dc=com ### 3. SSL/TLS Setup -For HTTPS, use a reverse proxy like nginx: +For HTTPS, use a reverse proxy like nginx. The proxy should set the `X-Forwarded-Proto` header so Rails knows the original request was HTTPS. + +> **Important**: Keep `RAILS_FORCE_SSL=true` (default) when using a reverse proxy. Only set `RAILS_FORCE_SSL=false` for local Docker testing without SSL termination. + +Example nginx configuration: ```nginx server { diff --git a/docs/getting-started/environment-variables.md b/docs/getting-started/environment-variables.md index 30efb934d..b003673c0 100644 --- a/docs/getting-started/environment-variables.md +++ b/docs/getting-started/environment-variables.md @@ -204,7 +204,7 @@ This ensures OIDC auto-discovery events and all application logs are visible in | `RAILS_MASTER_KEY` | Rails master key for credentials | - | Generated by Rails | | `RAILS_LOG_TO_STDOUT` | Log to stdout instead of files | - | `true` | | `RAILS_SERVE_STATIC_FILES` | Serve static files in production | - | `true` | -| `FORCE_SSL` | Force SSL connections | - | `true` | +| `RAILS_FORCE_SSL` | Force HTTPS redirects (set to `false` for Docker without SSL termination) | `true` | `false` | ## Container Logging (Production) diff --git a/spec/config/ssl_configuration_spec.rb b/spec/config/ssl_configuration_spec.rb new file mode 100644 index 000000000..8d1c760a0 --- /dev/null +++ b/spec/config/ssl_configuration_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'SSL Configuration', type: :request do + describe 'production environment SSL settings' do + # These tests validate the fix for GitHub issues #700 and #702 + # - #700: Infinite redirect loop between /users/sign_in and / + # - #702: Puma HTTP parse error on clean Docker install + # + # Root cause: Rails 8 defaults hardcoded assume_ssl=true and force_ssl=true + # which breaks Docker quickstart deployments without SSL termination. + + it 'assume_ssl should be false (do not blindly assume SSL)' do + # assume_ssl=true causes issues when accessing app directly without proxy + production_rb = File.read(Rails.root.join('config/environments/production.rb')) + + expect(production_rb).to include('config.assume_ssl = false'), + 'assume_ssl should be false - do not blindly assume SSL termination' + end + + it 'force_ssl should be ENV-configurable with secure default' do + production_rb = File.read(Rails.root.join('config/environments/production.rb')) + + # Should use ENV.fetch pattern for configurability + expect(production_rb).to include('RAILS_FORCE_SSL'), + 'force_ssl should be configurable via RAILS_FORCE_SSL env var' + + # Should default to true (secure by default) + expect(production_rb).to match(/ENV\.fetch.*RAILS_FORCE_SSL.*true/), + 'force_ssl should default to true for security' + end + + it 'force_ssl can be disabled by setting RAILS_FORCE_SSL=false' do + production_rb = File.read(Rails.root.join('config/environments/production.rb')) + + # Should check for "false" string to disable + expect(production_rb).to match(/downcase.*!=.*"false"|\.downcase != "false"/), + 'force_ssl should be disableable by setting RAILS_FORCE_SSL=false' + end + end +end From eae7f0d64e011f8249518994f7fa5aac5f6e1061 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 27 Jan 2026 20:59:22 -0500 Subject: [PATCH 002/428] fix: Ensure JavaScript assets are built before running tests Tests were failing with confusing "asset not found" errors when JavaScript assets weren't compiled. This adds safeguards at three levels: 1. CI/CD: Add `yarn build` step to GitHub Actions workflow 2. Local setup: Add `yarn install` + `yarn build` to bin/setup 3. Test runtime: Add early check in rails_helper.rb that fails fast with a clear error message if assets are missing This prevents developers from wasting time debugging redirect loops that are actually caused by missing JavaScript assets. Authored by: Aaron Lippold --- .github/workflows/run-tests.yml | 2 ++ bin/setup | 6 ++++++ spec/rails_helper.rb | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 77e2d395b..a12dc4944 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -59,6 +59,8 @@ jobs: bundle config path vendor/bundle bundle install --jobs 4 --retry 3 yarn install --frozen-lockfile + - name: Build JavaScript assets + run: yarn build - name: Run Rubocop run: bundle exec rubocop - name: Run eslint diff --git a/bin/setup b/bin/setup index be3db3c0d..a2f0f04f8 100755 --- a/bin/setup +++ b/bin/setup @@ -15,6 +15,12 @@ FileUtils.chdir APP_ROOT do puts "== Installing dependencies ==" system("bundle check") || system!("bundle install") + puts "\n== Installing JavaScript dependencies ==" + system! "yarn install --frozen-lockfile" + + puts "\n== Building JavaScript assets ==" + system! "yarn build" + # puts "\n== Copying sample files ==" # unless File.exist?("config/database.yml") # FileUtils.cp "config/database.yml.sample", "config/database.yml" diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 8fdb7f91b..47ebb2b8b 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -16,6 +16,26 @@ require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! +# Check that JavaScript assets are built before running tests +# This prevents confusing failures where views can't find JS files +assets_dir = Rails.root.join('app/assets/builds') +unless assets_dir.exist? && assets_dir.glob('*.js').any? + abort <<~ERROR + \e[31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ERROR: JavaScript assets not built! + + Tests require compiled JavaScript assets. Please run: + + yarn install --frozen-lockfile && yarn build + + Or run the full setup: + + bin/setup --skip-server + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\e[0m + ERROR +end + # Rails 8 lazy loading fix - ensure Devise routes are loaded for tests Rails.application.reload_routes! From 82c840a119944879abb686d59df9e3e629e562b7 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 27 Jan 2026 21:22:10 -0500 Subject: [PATCH 003/428] fix: Resolve nested attributes not saving in rules controller (#692) Root Cause: Rails 8's params.expect with nested arrays was marking them as permitted: false, causing nested attributes to be filtered out. Fix: Changed rule_update_params from params.expect to params.require().permit() which properly handles nested array parameters. Impact: - Fixes bug where check text, fix text, vuln descriptions weren't persisting - Users reported changes reverting after save - now resolved - Affects checks_attributes, disa_rule_descriptions_attributes, rule_descriptions_attributes, additional_answers_attributes Testing: Added spec/requests/rules_spec.rb with tests for: - Check content updates - Fixtext updates - DISA rule description updates - Status updates - Multi-field updates simulating real frontend behavior Fixes #692 Authored by: Aaron Lippold --- app/controllers/rules_controller.rb | 29 +++--- spec/requests/rules_spec.rb | 153 ++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 spec/requests/rules_spec.rb diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 59b4f2733..3eb0d8205 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -146,19 +146,22 @@ def rule_create_params end def rule_update_params - params.expect( - rule: [:status, :status_justification, :artifact_description, :vendor_comments, - :rule_severity, :rule_weight, :version, :title, :ident, :ident_system, :fixtext, - :fix_id, :fixtext_fixref, :audit_comment, :inspec_control_body, :inspec_control_file, - :inspec_control_body_lang, :inspec_control_file_lang, - { checks_attributes: %i[id system content_ref_name content_ref_href content _destroy], - rule_descriptions_attributes: %i[id description _destroy], - additional_answers_attributes: %i[id additional_question_id answer], - disa_rule_descriptions_attributes: %i[ - id vuln_discussion false_positives false_negatives documentable mitigations_available - mitigations poam_available poam severity_override_guidance potential_impacts - third_party_tools mitigation_control responsibility ia_controls _destroy - ] }] + # Rails 8: Use require.permit for nested array attributes (checks_attributes, etc.) + # params.expect doesn't handle nested arrays well - causes them to be filtered out + # See: https://github.com/mitre/vulcan/issues/692 + params.require(:rule).permit( + :status, :status_justification, :artifact_description, :vendor_comments, + :rule_severity, :rule_weight, :version, :title, :ident, :ident_system, :fixtext, + :fix_id, :fixtext_fixref, :audit_comment, :inspec_control_body, :inspec_control_file, + :inspec_control_body_lang, :inspec_control_file_lang, + checks_attributes: %i[id system content_ref_name content_ref_href content _destroy], + rule_descriptions_attributes: %i[id description _destroy], + additional_answers_attributes: %i[id additional_question_id answer], + disa_rule_descriptions_attributes: %i[ + id vuln_discussion false_positives false_negatives documentable mitigations_available + mitigations poam_available poam severity_override_guidance potential_impacts + third_party_tools mitigation_control responsibility ia_controls _destroy + ] ) end diff --git a/spec/requests/rules_spec.rb b/spec/requests/rules_spec.rb new file mode 100644 index 000000000..3d0deadfc --- /dev/null +++ b/spec/requests/rules_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Rules', type: :request do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:component) { create(:component, project: project) } + let(:rule) { component.rules.first } + + before do + Rails.application.reload_routes! + sign_in user + Membership.create!(user: user, membership: project, role: 'admin') + end + + describe 'PUT /rules/:id' do + context 'when updating nested attributes' do + it 'updates check content (check text)' do + original_content = rule.checks.first.content + new_content = 'Updated check text content' + + put "/rules/#{rule.id}", params: { + rule: { + checks_attributes: [ + { + id: rule.checks.first.id, + content: new_content + } + ] + } + } + + expect(response).to have_http_status(:success) + expect(rule.checks.first.reload.content).to eq(new_content) + expect(rule.checks.first.content).not_to eq(original_content) + end + + it 'updates fixtext (fix text)' do + original_fixtext = rule.fixtext + new_fixtext = 'Updated fix text content' + + put "/rules/#{rule.id}", params: { + rule: { + fixtext: new_fixtext + } + } + + expect(response).to have_http_status(:success) + expect(rule.reload.fixtext).to eq(new_fixtext) + expect(rule.fixtext).not_to eq(original_fixtext) + end + + it 'updates disa_rule_description vuln_discussion (vuln description)' do + original_vuln_discussion = rule.disa_rule_descriptions.first.vuln_discussion + new_vuln_discussion = 'Updated vulnerability discussion content' + + put "/rules/#{rule.id}", params: { + rule: { + disa_rule_descriptions_attributes: [ + { + id: rule.disa_rule_descriptions.first.id, + vuln_discussion: new_vuln_discussion + } + ] + } + } + + expect(response).to have_http_status(:success) + expect(rule.disa_rule_descriptions.first.reload.vuln_discussion).to eq(new_vuln_discussion) + expect(rule.disa_rule_descriptions.first.vuln_discussion).not_to eq(original_vuln_discussion) + end + + it 'updates status' do + original_status = rule.status + new_status = 'Applicable - Inherently Meets' + + put "/rules/#{rule.id}", params: { + rule: { + status: new_status + } + } + + expect(response).to have_http_status(:success) + expect(rule.reload.status).to eq(new_status) + expect(rule.status).not_to eq(original_status) + end + + it 'updates multiple fields at once (simulating real frontend behavior)' do + new_title = 'Updated Title' + new_fixtext = 'Updated Fix Text' + new_check_content = 'Updated Check Content' + new_vuln_discussion = 'Updated Vuln Discussion' + + put "/rules/#{rule.id}", params: { + rule: { + title: new_title, + fixtext: new_fixtext, + audit_comment: 'Testing multi-field update', + checks_attributes: [ + { + id: rule.checks.first.id, + system: rule.checks.first.system, + content_ref_name: rule.checks.first.content_ref_name, + content_ref_href: rule.checks.first.content_ref_href, + content: new_check_content, + _destroy: false + } + ], + disa_rule_descriptions_attributes: [ + { + id: rule.disa_rule_descriptions.first.id, + vuln_discussion: new_vuln_discussion, + _destroy: false + } + ] + } + } + + expect(response).to have_http_status(:success) + + rule.reload + expect(rule.title).to eq(new_title) + expect(rule.fixtext).to eq(new_fixtext) + expect(rule.checks.first.content).to eq(new_check_content) + expect(rule.disa_rule_descriptions.first.vuln_discussion).to eq(new_vuln_discussion) + end + end + + context 'when updating without id in nested attributes' do + it 'should still work or show clear error' do + new_content = 'Updated without id' + + put "/rules/#{rule.id}", params: { + rule: { + checks_attributes: [ + { + # Deliberately omitting id to test behavior + content: new_content + } + ] + } + } + + # This might create a new check instead of updating + # Let's verify the behavior + rule.reload + # Either the existing check is updated OR a new one is created + expect(rule.checks.pluck(:content)).to include(new_content) + end + end + end +end From 3185f8b23e919d490cb873b13136cc9d6fa6b0e7 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 27 Jan 2026 21:49:55 -0500 Subject: [PATCH 004/428] fix: Vue reactivity for satisfied_by relationship field visibility When adding a satisfied_by relationship to a control, the UI now properly shows fields appropriate for "Applicable - Configurable" status. Root Cause: Computed properties and template conditions only checked rule.status, not considering rule.satisfied_by.length. Rules with satisfied_by relationships should behave like "Applicable - Configurable" status. Changes: - BasicRuleForm.vue: Updated disaDescriptionFormFields and checkFormFields - AdvancedRuleForm.vue: Updated template v-if conditions and computed props - RuleForm.vue: Updated CheckForm v-if condition - CheckForm.vue: Updated tooltips computed property Impact: - UI correctly shows/hides fields when satisfied_by relationships change - Check text, vuln discussion fields properly visible after adding satisfied_by - Fields no longer disappear unexpectedly after nesting controls Authored by: Aaron Lippold --- .../components/rules/forms/AdvancedRuleForm.vue | 8 +++++--- app/javascript/components/rules/forms/BasicRuleForm.vue | 7 ++++--- app/javascript/components/rules/forms/CheckForm.vue | 4 +++- app/javascript/components/rules/forms/RuleForm.vue | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/javascript/components/rules/forms/AdvancedRuleForm.vue b/app/javascript/components/rules/forms/AdvancedRuleForm.vue index 4c61b56d8..38525aadf 100644 --- a/app/javascript/components/rules/forms/AdvancedRuleForm.vue +++ b/app/javascript/components/rules/forms/AdvancedRuleForm.vue @@ -51,6 +51,7 @@ @@ -536,21 +536,20 @@ export default { } }, toggleAdvancedFields(advancedFields) { - if ( - confirm( - `Are you sure you want to ${advancedFields ? "enable" : "disable"} advanced fields?`, - ) - ) { - const payload = { - component: { - advanced_fields: advancedFields, - }, - }; - axios - .patch(`/components/${this.component.id}`, payload) - .then(this.alertOrNotifyResponse) - .catch(this.alertOrNotifyResponse); - } + // Confirmation is now handled in RuleEditor component + const payload = { + component: { + advanced_fields: advancedFields, + }, + }; + axios + .patch(`/components/${this.component.id}`, payload) + .then((response) => { + this.alertOrNotifyResponse(response); + // Update local component state for reactivity + this.component.advanced_fields = advancedFields; + }) + .catch(this.alertOrNotifyResponse); }, lockRule(comment) { if (!comment.trim()) return; diff --git a/app/javascript/components/shared/ControlsCommandBar.vue b/app/javascript/components/shared/ControlsCommandBar.vue index 78cafaa6b..a09751cde 100644 --- a/app/javascript/components/shared/ControlsCommandBar.vue +++ b/app/javascript/components/shared/ControlsCommandBar.vue @@ -33,16 +33,6 @@ Release - - - Advanced - @@ -167,9 +157,6 @@ export default { isReleasable() { return this.component.releasable && !this.component.released; }, - canToggleAdvancedFields() { - return this.effectivePermissions === "admin"; - }, hasSelectedRule() { return !!this.selectedRule; }, @@ -199,9 +186,6 @@ export default { onRelease() { this.$emit("release"); }, - onToggleAdvancedFields(value) { - this.$emit("toggle-advanced-fields", value); - }, onOpenMembers() { this.$emit("open-members"); }, diff --git a/spec/javascript/components/rules/RuleEditor.spec.js b/spec/javascript/components/rules/RuleEditor.spec.js index fd65205d1..f29741c71 100644 --- a/spec/javascript/components/rules/RuleEditor.spec.js +++ b/spec/javascript/components/rules/RuleEditor.spec.js @@ -112,4 +112,129 @@ describe('RuleEditor', () => { expect(wrapper.emitted('open-related-modal')).toBeTruthy() }) }) + + // ========================================== + // ADVANCED FIELDS TOGGLE + // ========================================== + describe('Advanced Fields toggle', () => { + /** + * REQUIREMENTS: + * 1. Toggle is ALWAYS visible (not conditional on advanced_fields prop) + * 2. Toggle reflects component.advanced_fields state (from props) + * 3. When enabling, show confirmation dialog with warning + * 4. When confirmed, emit toggle-advanced-fields event + * 5. When canceled, do not emit event + * 6. Shows AdvancedRuleForm when advanced_fields is true + * 7. Helper text explains most users don't need this + */ + + it('always shows Advanced Fields toggle regardless of advanced_fields prop', () => { + // Even when advanced_fields is false, toggle should be visible + wrapper = createWrapper({ advanced_fields: false }) + const toggle = wrapper.find('[data-testid="advanced-fields-toggle"]') + expect(toggle.exists()).toBe(true) + }) + + it('toggle reflects current advanced_fields prop value', async () => { + wrapper = createWrapper({ advanced_fields: true }) + const checkbox = wrapper.find('[data-testid="advanced-fields-toggle"] input[type="checkbox"]') + expect(checkbox.element.checked).toBe(true) + }) + + it('toggle is unchecked when advanced_fields is false', () => { + wrapper = createWrapper({ advanced_fields: false }) + const checkbox = wrapper.find('[data-testid="advanced-fields-toggle"] input[type="checkbox"]') + expect(checkbox.element.checked).toBe(false) + }) + + it('shows confirmation dialog when enabling advanced fields', async () => { + wrapper = createWrapper({ advanced_fields: false }) + // Call the method directly to simulate checkbox change + wrapper.vm.onAdvancedFieldsToggle(true) + await wrapper.vm.$nextTick() + + // Modal should be shown + expect(wrapper.vm.showConfirmModal).toBe(true) + }) + + it('emits toggle-advanced-fields when confirmation is accepted', async () => { + wrapper = createWrapper({ advanced_fields: false }) + // Trigger the toggle + wrapper.vm.onAdvancedFieldsToggle(true) + await wrapper.vm.$nextTick() + + // Confirm + wrapper.vm.confirmEnableAdvanced() + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('toggle-advanced-fields')).toBeTruthy() + expect(wrapper.emitted('toggle-advanced-fields')[0]).toEqual([true]) + }) + + it('does not emit event when confirmation is canceled', async () => { + wrapper = createWrapper({ advanced_fields: false }) + // Trigger the toggle + wrapper.vm.onAdvancedFieldsToggle(true) + await wrapper.vm.$nextTick() + + // Cancel + wrapper.vm.cancelEnableAdvanced() + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('toggle-advanced-fields')).toBeFalsy() + }) + + it('resets checkbox state when confirmation is canceled', async () => { + wrapper = createWrapper({ advanced_fields: false }) + // Simulate checkbox being clicked (which sets localAdvancedFields to true) + wrapper.vm.localAdvancedFields = true + wrapper.vm.onAdvancedFieldsToggle(true) + await wrapper.vm.$nextTick() + + // Cancel should reset local state back to prop value + wrapper.vm.cancelEnableAdvanced() + await wrapper.vm.$nextTick() + + expect(wrapper.vm.localAdvancedFields).toBe(false) + }) + + it('emits toggle-advanced-fields immediately when disabling (no confirmation needed)', async () => { + wrapper = createWrapper({ advanced_fields: true }) + // Call method directly - disabling should emit immediately + wrapper.vm.onAdvancedFieldsToggle(false) + await wrapper.vm.$nextTick() + + // Should emit immediately without confirmation + expect(wrapper.emitted('toggle-advanced-fields')).toBeTruthy() + expect(wrapper.emitted('toggle-advanced-fields')[0]).toEqual([false]) + }) + + it('shows AdvancedRuleForm when advanced_fields is true', () => { + wrapper = createWrapper({ advanced_fields: true }) + expect(wrapper.findComponent({ name: 'AdvancedRuleForm' }).exists()).toBe(true) + }) + + it('hides AdvancedRuleForm when advanced_fields is false', () => { + wrapper = createWrapper({ advanced_fields: false }) + expect(wrapper.findComponent({ name: 'AdvancedRuleForm' }).exists()).toBe(false) + }) + + it('shows helper text explaining advanced fields are not needed by most users', () => { + wrapper = createWrapper({ advanced_fields: false }) + const helperText = wrapper.find('[data-testid="advanced-fields-helper"]') + expect(helperText.exists()).toBe(true) + expect(helperText.text().toLowerCase()).toContain('most users') + }) + + it('syncs local state when prop changes (e.g., after API update)', async () => { + wrapper = createWrapper({ advanced_fields: false }) + expect(wrapper.vm.localAdvancedFields).toBe(false) + + // Simulate parent updating prop after API call + await wrapper.setProps({ advanced_fields: true }) + + // Local state should sync with prop + expect(wrapper.vm.localAdvancedFields).toBe(true) + }) + }) }) diff --git a/spec/javascript/components/shared/ControlsCommandBar.spec.js b/spec/javascript/components/shared/ControlsCommandBar.spec.js index 75ebb675e..e970d4ffd 100644 --- a/spec/javascript/components/shared/ControlsCommandBar.spec.js +++ b/spec/javascript/components/shared/ControlsCommandBar.spec.js @@ -21,7 +21,7 @@ localVue.use(IconsPlugin) * - Edit/View button: toggles based on mode, requires author+ permission * - Release button: admin only, disabled when not releasable * - Members button: always visible, opens members modal - * - Advanced Fields toggle: admin only + * - Advanced Fields toggle: MOVED to RuleEditor (per-component DB setting) * * 3. COMPONENT PANELS (right side): * - Details, Metadata, Questions, Comp History, Comp Reviews @@ -202,24 +202,7 @@ describe('ControlsCommandBar', () => { }) }) - describe('Advanced Fields toggle', () => { - it('shows toggle for admin', () => { - wrapper = createWrapper({ effectivePermissions: 'admin' }) - expect(wrapper.text()).toContain('Advanced') - }) - - it('hides toggle for non-admin', () => { - wrapper = createWrapper({ effectivePermissions: 'author' }) - expect(wrapper.text()).not.toContain('Advanced') - }) - - it('emits toggle-advanced-fields when changed', async () => { - wrapper = createWrapper({ effectivePermissions: 'admin' }) - const checkbox = wrapper.find('input[type="checkbox"]') - await checkbox.setChecked(true) - expect(wrapper.emitted('toggle-advanced-fields')).toBeTruthy() - }) - }) + // Advanced Fields toggle moved to RuleEditor (per-component setting) // ========================================== // COMPONENT PANELS From 267609228419f6a46723066bbf6b808fc047c731 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 4 Feb 2026 16:29:04 -0500 Subject: [PATCH 066/428] feat: Add ProjectCommandBar and ProjectSidepanels components New components for Project page standardization: ProjectCommandBar.vue: - Visibility toggle (admin only) - New Component button (admin only) - Download dropdown - Panel toggle buttons: Details, Metadata, History ProjectSidepanels.vue: - proj-details: Project name, description, status summary - proj-metadata: Key-value metadata with edit modal - proj-history: Project history via History component Tests: 31 new tests (16 + 15) Authored by: Aaron Lippold --- .../components/project/ProjectCommandBar.vue | 129 +++++++++++ .../components/project/ProjectSidepanels.vue | 174 +++++++++++++++ .../project/ProjectCommandBar.spec.js | 203 ++++++++++++++++++ .../project/ProjectSidepanels.spec.js | 198 +++++++++++++++++ 4 files changed, 704 insertions(+) create mode 100644 app/javascript/components/project/ProjectCommandBar.vue create mode 100644 app/javascript/components/project/ProjectSidepanels.vue create mode 100644 spec/javascript/components/project/ProjectCommandBar.spec.js create mode 100644 spec/javascript/components/project/ProjectSidepanels.spec.js diff --git a/app/javascript/components/project/ProjectCommandBar.vue b/app/javascript/components/project/ProjectCommandBar.vue new file mode 100644 index 000000000..f06d27134 --- /dev/null +++ b/app/javascript/components/project/ProjectCommandBar.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/app/javascript/components/project/ProjectSidepanels.vue b/app/javascript/components/project/ProjectSidepanels.vue new file mode 100644 index 000000000..09bd79a5d --- /dev/null +++ b/app/javascript/components/project/ProjectSidepanels.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/spec/javascript/components/project/ProjectCommandBar.spec.js b/spec/javascript/components/project/ProjectCommandBar.spec.js new file mode 100644 index 000000000..86c0ca805 --- /dev/null +++ b/spec/javascript/components/project/ProjectCommandBar.spec.js @@ -0,0 +1,203 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { mount, createLocalVue } from '@vue/test-utils' +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' +import ProjectCommandBar from '@/components/project/ProjectCommandBar.vue' +import { PANEL_LABELS } from '@/constants/terminology' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) +localVue.use(IconsPlugin) + +/** + * ProjectCommandBar - Command Bar for Project page + * + * REQUIREMENTS: + * + * 1. ACTIONS (left side): + * - Visibility toggle: admin only, switches discoverable/hidden + * - Download dropdown: always visible, export options + * - New Component button: admin only + * + * 2. PANEL BUTTONS (right side): + * - Project Details: always visible + * - Project Metadata: always visible + * - Project History: always visible + * - All emit 'toggle-panel' with panel name + * + * 3. VISUAL FEEDBACK: + * - Active panel button has 'secondary' variant + * - Inactive panel buttons have 'outline-secondary' variant + * + * 4. CONSISTENCY: + * - Uses same styling pattern as ControlsCommandBar + * - Uses PANEL_LABELS from terminology constants + */ +describe('ProjectCommandBar', () => { + let wrapper + + const defaultProps = { + project: { + id: 1, + name: 'Test Project', + visibility: 'hidden', + components: [] + }, + effectivePermissions: 'admin', + activePanel: null + } + + const createWrapper = (props = {}) => { + return mount(ProjectCommandBar, { + localVue, + propsData: { + ...defaultProps, + ...props + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + // ========================================== + // BASIC RENDERING + // ========================================== + describe('basic rendering', () => { + it('renders the command bar', () => { + wrapper = createWrapper() + expect(wrapper.find('.command-bar').exists()).toBe(true) + }) + + it('renders panel buttons on the right', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Details') + expect(wrapper.text()).toContain('Metadata') + expect(wrapper.text()).toContain('History') + }) + }) + + // ========================================== + // VISIBILITY TOGGLE (Admin only) + // ========================================== + describe('visibility toggle', () => { + it('shows visibility toggle for admin', () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + const toggle = wrapper.find('[data-testid="visibility-toggle"]') + expect(toggle.exists()).toBe(true) + }) + + it('hides visibility toggle for non-admin', () => { + wrapper = createWrapper({ effectivePermissions: 'author' }) + const toggle = wrapper.find('[data-testid="visibility-toggle"]') + expect(toggle.exists()).toBe(false) + }) + + it('emits toggle-visibility when changed', async () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + const checkbox = wrapper.find('[data-testid="visibility-toggle"] input[type="checkbox"]') + await checkbox.setChecked(true) + expect(wrapper.emitted('toggle-visibility')).toBeTruthy() + }) + }) + + // ========================================== + // DOWNLOAD DROPDOWN + // ========================================== + describe('download dropdown', () => { + it('shows download dropdown', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Download') + }) + + it('emits download event with type when option clicked', async () => { + wrapper = createWrapper() + // Find and click dropdown item + const dropdown = wrapper.find('[data-testid="download-dropdown"]') + expect(dropdown.exists()).toBe(true) + }) + }) + + // ========================================== + // NEW COMPONENT BUTTON (Admin only) + // ========================================== + describe('new component button', () => { + it('shows new component button for admin', () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + const btn = wrapper.find('[data-testid="new-component-btn"]') + expect(btn.exists()).toBe(true) + }) + + it('hides new component button for non-admin', () => { + wrapper = createWrapper({ effectivePermissions: 'viewer' }) + const btn = wrapper.find('[data-testid="new-component-btn"]') + expect(btn.exists()).toBe(false) + }) + + it('emits new-component when clicked', async () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + const btn = wrapper.find('[data-testid="new-component-btn"]') + await btn.trigger('click') + expect(wrapper.emitted('new-component')).toBeTruthy() + }) + }) + + // ========================================== + // PANEL BUTTONS + // ========================================== + describe('panel buttons', () => { + it('renders all 3 project panel buttons', () => { + wrapper = createWrapper() + const buttons = wrapper.findAll('button') + const panelButtons = buttons.wrappers.filter(b => + b.text().includes('Details') || + b.text().includes('Metadata') || + b.text().includes('History') + ) + expect(panelButtons.length).toBe(3) + }) + + it('emits toggle-panel with "proj-details" when Details clicked', async () => { + wrapper = createWrapper() + const btn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Details')) + await btn.trigger('click') + expect(wrapper.emitted('toggle-panel')).toBeTruthy() + expect(wrapper.emitted('toggle-panel')[0]).toEqual(['proj-details']) + }) + + it('emits toggle-panel with "proj-metadata" when Metadata clicked', async () => { + wrapper = createWrapper() + const btn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Metadata')) + await btn.trigger('click') + expect(wrapper.emitted('toggle-panel')).toBeTruthy() + expect(wrapper.emitted('toggle-panel')[0]).toEqual(['proj-metadata']) + }) + + it('emits toggle-panel with "proj-history" when History clicked', async () => { + wrapper = createWrapper() + const btn = wrapper.findAll('button').wrappers.find(b => b.text().includes('History')) + await btn.trigger('click') + expect(wrapper.emitted('toggle-panel')).toBeTruthy() + expect(wrapper.emitted('toggle-panel')[0]).toEqual(['proj-history']) + }) + }) + + // ========================================== + // ACTIVE PANEL VISUAL FEEDBACK + // ========================================== + describe('active panel visual feedback', () => { + it('shows secondary variant when panel is active', () => { + wrapper = createWrapper({ activePanel: 'proj-details' }) + const detailsBtn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Details')) + expect(detailsBtn.classes()).toContain('btn-secondary') + }) + + it('shows outline-secondary variant when panel is inactive', () => { + wrapper = createWrapper({ activePanel: null }) + const detailsBtn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Details')) + expect(detailsBtn.classes()).toContain('btn-outline-secondary') + }) + }) +}) diff --git a/spec/javascript/components/project/ProjectSidepanels.spec.js b/spec/javascript/components/project/ProjectSidepanels.spec.js new file mode 100644 index 000000000..5c10a360f --- /dev/null +++ b/spec/javascript/components/project/ProjectSidepanels.spec.js @@ -0,0 +1,198 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { shallowMount, createLocalVue } from '@vue/test-utils' +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' +import ProjectSidepanels from '@/components/project/ProjectSidepanels.vue' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) +localVue.use(IconsPlugin) + +/** + * ProjectSidepanels - Slideover panels for Project page + * + * REQUIREMENTS: + * + * 1. PANELS: + * - proj-details: Shows project details (name, description, stats) + * - proj-metadata: Shows project metadata with edit capability + * - proj-history: Shows project history + * + * 2. VISIBILITY: + * - Each panel opens when activePanel matches its ID + * - Emits 'close-panel' when sidebar is hidden + * + * 3. EDIT CAPABILITIES: + * - Details: UpdateProjectDetailsModal for admin + * - Metadata: UpdateMetadataModal for author+ + * + * 4. CONSISTENCY: + * - Uses same b-sidebar pattern as ControlsSidepanels + * - Right-aligned slideovers + */ +describe('ProjectSidepanels', () => { + let wrapper + + const defaultProps = { + project: { + id: 1, + name: 'Test Project', + description: 'Test description', + visibility: 'hidden', + metadata: { key: 'value' }, + histories: [{ id: 1, action: 'created' }], + details: { + ac: 10, + aim: 5, + adnm: 2, + na: 3, + nyd: 20, + nur: 15, + ur: 5, + lck: 2, + total: 62 + } + }, + effectivePermissions: 'admin', + activePanel: null + } + + const createWrapper = (props = {}) => { + return shallowMount(ProjectSidepanels, { + localVue, + propsData: { + ...defaultProps, + ...props + }, + stubs: { + BSidebar: true, + History: true, + UpdateProjectDetailsModal: true, + UpdateMetadataModal: true + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + // ========================================== + // BASIC RENDERING + // ========================================== + describe('basic rendering', () => { + it('renders the component', () => { + wrapper = createWrapper() + expect(wrapper.exists()).toBe(true) + }) + + it('renders all 3 sidebars', () => { + wrapper = createWrapper() + const sidebars = wrapper.findAllComponents({ name: 'BSidebar' }) + expect(sidebars.length).toBe(3) + }) + }) + + // ========================================== + // PANEL VISIBILITY + // ========================================== + describe('panel visibility', () => { + it('shows proj-details sidebar when activePanel is proj-details', () => { + wrapper = createWrapper({ activePanel: 'proj-details' }) + const sidebar = wrapper.find('[data-testid="proj-details-sidebar"]') + expect(sidebar.attributes('visible')).toBe('true') + }) + + it('shows proj-metadata sidebar when activePanel is proj-metadata', () => { + wrapper = createWrapper({ activePanel: 'proj-metadata' }) + const sidebar = wrapper.find('[data-testid="proj-metadata-sidebar"]') + expect(sidebar.attributes('visible')).toBe('true') + }) + + it('shows proj-history sidebar when activePanel is proj-history', () => { + wrapper = createWrapper({ activePanel: 'proj-history' }) + const sidebar = wrapper.find('[data-testid="proj-history-sidebar"]') + expect(sidebar.attributes('visible')).toBe('true') + }) + + it('hides all sidebars when activePanel is null', () => { + wrapper = createWrapper({ activePanel: null }) + const detailsSidebar = wrapper.find('[data-testid="proj-details-sidebar"]') + const metadataSidebar = wrapper.find('[data-testid="proj-metadata-sidebar"]') + const historySidebar = wrapper.find('[data-testid="proj-history-sidebar"]') + expect(detailsSidebar.attributes('visible')).toBeFalsy() + expect(metadataSidebar.attributes('visible')).toBeFalsy() + expect(historySidebar.attributes('visible')).toBeFalsy() + }) + }) + + // ========================================== + // CLOSE PANEL EVENT + // ========================================== + describe('close panel event', () => { + it('emits close-panel when sidebar is hidden', async () => { + wrapper = createWrapper({ activePanel: 'proj-details' }) + const sidebar = wrapper.findComponent({ name: 'BSidebar' }) + sidebar.vm.$emit('hidden') + expect(wrapper.emitted('close-panel')).toBeTruthy() + }) + }) + + // ========================================== + // PROJECT DETAILS CONTENT + // ========================================== + describe('project details panel', () => { + it('displays project name', () => { + wrapper = createWrapper({ activePanel: 'proj-details' }) + expect(wrapper.text()).toContain('Test Project') + }) + + it('displays project description', () => { + wrapper = createWrapper({ activePanel: 'proj-details' }) + expect(wrapper.text()).toContain('Test description') + }) + + it('displays status counts', () => { + wrapper = createWrapper({ activePanel: 'proj-details' }) + // Should show AC, AIM, ADNM, NA, NYD counts + expect(wrapper.text()).toContain('Applicable - Configurable') + }) + + it('shows edit button for admin', () => { + wrapper = createWrapper({ activePanel: 'proj-details', effectivePermissions: 'admin' }) + expect(wrapper.findComponent({ name: 'UpdateProjectDetailsModal' }).exists()).toBe(true) + }) + }) + + // ========================================== + // PROJECT METADATA CONTENT + // ========================================== + describe('project metadata panel', () => { + it('displays metadata key-value pairs', () => { + wrapper = createWrapper({ activePanel: 'proj-metadata' }) + expect(wrapper.text()).toContain('key') + }) + + it('shows edit button for author+', () => { + wrapper = createWrapper({ activePanel: 'proj-metadata', effectivePermissions: 'author' }) + expect(wrapper.findComponent({ name: 'UpdateMetadataModal' }).exists()).toBe(true) + }) + }) + + // ========================================== + // PROJECT HISTORY CONTENT + // ========================================== + describe('project history panel', () => { + it('renders History component', () => { + wrapper = createWrapper({ activePanel: 'proj-history' }) + expect(wrapper.findComponent({ name: 'History' }).exists()).toBe(true) + }) + + it('passes histories to History component', () => { + wrapper = createWrapper({ activePanel: 'proj-history' }) + const history = wrapper.findComponent({ name: 'History' }) + expect(history.props('histories')).toEqual(defaultProps.project.histories) + }) + }) +}) From ad634e0f1e69823bf0c01322e7361aae7241a96b Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 4 Feb 2026 22:47:53 -0500 Subject: [PATCH 067/428] feat: Add unified ExportModal with format and component selection Created reusable ExportModal component that replaces dropdown-based exports with a single modal showing format selection (DISA Excel, Excel, InSpec, XCCDF) and component selection. Provides better UX with confirmation step and prevents accidental exports. - Created ExportModal.vue with format radio buttons and component checkboxes - Updated Project.vue to use ExportModal instead of inline export handling - Changed ProjectCommandBar Download dropdown to single button - Added v-model support for modal visibility - 31 ExportModal tests, all passing Authored by: Aaron Lippold --- app/javascript/components/project/Project.vue | 418 +++++------------- .../components/project/ProjectCommandBar.vue | 67 ++- .../components/shared/ExportModal.vue | 217 +++++++++ .../components/project/Project.spec.js | 345 +++++++++++++++ .../project/ProjectCommandBar.spec.js | 86 +++- .../components/shared/ExportModal.spec.js | 360 +++++++++++++++ 6 files changed, 1141 insertions(+), 352 deletions(-) create mode 100644 app/javascript/components/shared/ExportModal.vue create mode 100644 spec/javascript/components/project/Project.spec.js create mode 100644 spec/javascript/components/shared/ExportModal.spec.js diff --git a/app/javascript/components/project/Project.vue b/app/javascript/components/project/Project.vue index e7a72d6f5..0bb6fadb8 100644 --- a/app/javascript/components/project/Project.vue +++ b/app/javascript/components/project/Project.vue @@ -1,52 +1,36 @@ @@ -326,6 +131,7 @@ import DateFormatMixinVue from "../../mixins/DateFormatMixin.vue"; import FormMixinVue from "../../mixins/FormMixin.vue"; import AlertMixinVue from "../../mixins/AlertMixin.vue"; import RoleComparisonMixin from "../../mixins/RoleComparisonMixin.vue"; +import { useSidebar } from "../../composables"; import History from "../shared/History.vue"; import MembershipsTable from "../memberships/MembershipsTable.vue"; import UpdateMetadataModal from "./UpdateMetadataModal.vue"; @@ -335,6 +141,10 @@ import NewComponentModal from "../components/NewComponentModal.vue"; import DiffViewer from "./DiffViewer.vue"; import RevisionHistory from "./RevisionHistory.vue"; import UpdateProjectDetailsModal from "../projects/UpdateProjectDetailsModal.vue"; +import ProjectCommandBar from "./ProjectCommandBar.vue"; +import ProjectSidepanels from "./ProjectSidepanels.vue"; +import ExportModal from "../shared/ExportModal.vue"; +import ProjectMembersModal from "./ProjectMembersModal.vue"; export default { name: "Project", @@ -348,8 +158,16 @@ export default { DiffViewer, RevisionHistory, UpdateProjectDetailsModal, + ProjectCommandBar, + ProjectSidepanels, + ExportModal, + ProjectMembersModal, }, mixins: [DateFormatMixinVue, AlertMixinVue, FormMixinVue, RoleComparisonMixin], + setup() { + const { activePanel, togglePanel, closePanel } = useSidebar(); + return { activePanel, togglePanel, closePanel }; + }, props: { effective_permissions: { type: String, @@ -376,26 +194,16 @@ export default { }, data: function () { return { - showDetails: true, - showMetadata: true, - showHistory: true, project: this.initialProjectState, - visible: this.initialProjectState.visibility === "discoverable", projectTabIndex: 0, - excelExportType: "", - selectedComponentsToExport: [], - allComponentsSelected: false, - releasedComponentsSelected: false, - indeterminate: false, + // Export modal state + showExportModal: false, + // Visibility modal state + showVisibilityModal: false, + pendingVisibility: false, }; }, computed: { - excelExportComponentOptions: function () { - return this.sortedComponents().map((c) => { - const versionRelease = c.version && c.release ? ` - V${c.version}R${c.release}` : ""; - return { text: `${c.name}${versionRelease}`, value: c.id }; - }); - }, sortedAvailableComponents: function () { return _.sortBy(this.project.available_components, ["child_project_name"], ["asc"]); }, @@ -428,29 +236,6 @@ export default { JSON.stringify(this.projectTabIndex), ); }, - selectedComponentsToExport: function (newValue, oldValue) { - // Handle changes in individual component checkboxes - if (newValue.length === 0) { - this.indeterminate = false; - this.allComponentsSelected = false; - this.releasedComponentsSelected = false; - } else if (newValue.length === this.project.components.length) { - this.indeterminate = false; - this.allComponentsSelected = true; - this.releasedComponentsSelected = true; - } else if ( - this.releasedComponents().lenght > 0 && - this.releasedComponents().every((element) => newValue.includes(element)) - ) { - this.indeterminate = true; - this.allComponentsSelected = false; - this.releasedComponentsSelected = true; - } else { - this.indeterminate = true; - this.allComponentsSelected = false; - this.releasedComponentsSelected = false; - } - }, }, mounted: function () { // Persist `currentTab` across page loads @@ -468,18 +253,6 @@ export default { } }, methods: { - toggleComponents: function () { - if (this.allComponentsSelected) { - this.selectedComponentsToExport = this.project.components.map((comp) => comp.id); - } else if (this.releasedComponentsSelected) { - this.selectedComponentsToExport = this.releasedComponents(); - } else { - this.selectedComponentsToExport = []; - } - }, - releasedComponents: function () { - return this.project.components.filter((comp) => comp.released).map((comp) => comp.id); - }, sortedComponents: function () { return _.orderBy( this.project.components, @@ -499,8 +272,13 @@ export default { this.visible = this.project.visibility === "discoverable"; }); }, + showVisibilityConfirm(newValue) { + this.pendingVisibility = newValue; + this.showVisibilityModal = true; + }, updateVisibility: function () { - let payload = { project: { visibility: this.visible ? "discoverable" : "hidden" } }; + this.showVisibilityModal = false; + let payload = { project: { visibility: this.pendingVisibility ? "discoverable" : "hidden" } }; axios .put(`/projects/${this.project.id}`, payload) .then((response) => { @@ -509,6 +287,34 @@ export default { }) .catch(this.alertOrNotifyResponse); }, + cancelVisibilityChange() { + this.showVisibilityModal = false; + // Reset the command bar toggle to match actual project state + if (this.$refs.commandBar) { + this.$refs.commandBar.resetVisibilityToggle(); + } + }, + onVisibilityModalHidden() { + // Also reset on any modal close (backdrop click, escape key) + if (this.$refs.commandBar) { + this.$refs.commandBar.resetVisibilityToggle(); + } + }, + openNewComponentModal() { + if (this.$refs.newComponentModal) { + this.$refs.newComponentModal.showModal(); + } + }, + openExportModal() { + this.showExportModal = true; + }, + openMembersModal() { + this.$bvModal.show("project-members-modal"); + }, + executeExport({ type, componentIds }) { + // Called by ExportModal when user confirms export + this.downloadExport(type, componentIds); + }, // Having deleteComponent on the `ComponentCard` causes it to // disappear almost immediately because the component gets // destroyed once `refreshProject` executes @@ -521,23 +327,15 @@ export default { }) .catch(this.alertOrNotifyResponse); }, - downloadExport: function (type) { + downloadExport: function (type, componentIds) { axios - .get( - `/projects/${this.project.id}/export/${type}?component_ids=${this.selectedComponentsToExport}`, - ) + .get(`/projects/${this.project.id}/export/${type}?component_ids=${componentIds.join(",")}`) .then((_res) => { // Once it is validated that there is content to download, prompt // the user to save the file - window.open(`/projects/${this.project.id}/export/${type}`); + window.open(`/projects/${this.project.id}/export/${type}?component_ids=${componentIds.join(",")}`); }) .catch(this.alertOrNotifyResponse); - - if (type === "excel" || type === "disa_excel") { - this.$refs["excel-export-modal"].hide(); - this.excelExportType = ""; - this.selectedComponentsToExport = []; - } }, }, }; diff --git a/app/javascript/components/project/ProjectCommandBar.vue b/app/javascript/components/project/ProjectCommandBar.vue index f06d27134..535aff322 100644 --- a/app/javascript/components/project/ProjectCommandBar.vue +++ b/app/javascript/components/project/ProjectCommandBar.vue @@ -10,12 +10,12 @@ data-testid="visibility-toggle" > - {{ project.visibility === 'discoverable' ? 'Discoverable' : 'Hidden' }} + {{ localVisibility ? 'Discoverable' : 'Hidden' }} @@ -31,30 +31,18 @@ New Component - - + - - DISA Excel Export - - - Excel Export - - - InSpec Profile - - - XCCDF Export - - + Download + - +
- History + Activity + + + Revisions + + + Members
@@ -101,15 +101,36 @@ export default { default: null, }, }, + data() { + return { + // Local state for visibility toggle - syncs with prop + localVisibility: this.project.visibility === "discoverable", + }; + }, computed: { isAdmin() { return this.effectivePermissions === "admin"; }, }, + watch: { + // Sync local state when prop changes (after API success) + "project.visibility"(newVal) { + this.localVisibility = newVal === "discoverable"; + }, + }, methods: { isPanelActive(panel) { return this.activePanel === panel; }, + onVisibilityToggle(newValue) { + // Emit event for parent to show confirmation modal + // Parent will call resetVisibilityToggle if cancelled + this.$emit("toggle-visibility", newValue); + }, + // Called by parent when user cancels the confirmation + resetVisibilityToggle() { + this.localVisibility = this.project.visibility === "discoverable"; + }, }, }; diff --git a/app/javascript/components/shared/ExportModal.vue b/app/javascript/components/shared/ExportModal.vue new file mode 100644 index 000000000..8e7f03048 --- /dev/null +++ b/app/javascript/components/shared/ExportModal.vue @@ -0,0 +1,217 @@ + + + diff --git a/spec/javascript/components/project/Project.spec.js b/spec/javascript/components/project/Project.spec.js new file mode 100644 index 000000000..f462b3343 --- /dev/null +++ b/spec/javascript/components/project/Project.spec.js @@ -0,0 +1,345 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { shallowMount, createLocalVue } from '@vue/test-utils' +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' +import Project from '@/components/project/Project.vue' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) +localVue.use(IconsPlugin) + +// Mock axios - must include defaults.headers.common for FormMixin +vi.mock('axios', () => ({ + default: { + get: vi.fn(() => Promise.resolve({ data: {} })), + put: vi.fn(() => Promise.resolve({ data: {} })), + delete: vi.fn(() => Promise.resolve({ data: {} })), + defaults: { headers: { common: {} } } + } +})) + +/** + * Project Component Tests + * + * REQUIREMENTS: + * + * 1. LAYOUT: + * - Breadcrumb navigation + * - Command bar with actions and panel buttons + * - Full-width tabs (no right sidebar) + * - Slideover panels for Details, Metadata, History + * + * 2. COMMAND BAR INTEGRATION: + * - Renders ProjectCommandBar + * - Passes project, permissions, activePanel + * - Handles toggle-visibility, new-component, download, toggle-panel events + * + * 3. SIDEPANELS INTEGRATION: + * - Renders ProjectSidepanels + * - Passes project, permissions, activePanel + * - Handles close-panel, project-updated events + * + * 4. USESIDEBAR COMPOSABLE: + * - Has activePanel in component state + * - togglePanel opens/closes panels + * - closePanel closes active panel + */ +describe('Project', () => { + let wrapper + + const defaultProps = { + effective_permissions: 'admin', + initialProjectState: { + id: 1, + name: 'Test Project', + description: 'Test description', + visibility: 'hidden', + components: [], + available_components: [], + memberships: [], + memberships_count: 0, + access_requests: [], + metadata: {}, + histories: [], + details: { + ac: 10, + aim: 5, + adnm: 2, + na: 3, + nyd: 20, + nur: 15, + ur: 5, + lck: 2, + total: 62 + } + }, + current_user_id: 1, + statuses: ['Not Yet Determined', 'Applicable - Configurable'], + severities: ['low', 'medium', 'high'], + available_roles: ['admin', 'author', 'viewer'] + } + + const createWrapper = (props = {}) => { + return shallowMount(Project, { + localVue, + propsData: { + ...defaultProps, + ...props + }, + stubs: { + ProjectCommandBar: true, + ProjectSidepanels: true, + ExportModal: true, + BBreadcrumb: true, + BTabs: true, + BTab: true, + BModal: true, + ComponentCard: true, + NewComponentModal: true, + AddComponentModal: true, + DiffViewer: true, + RevisionHistory: true, + MembershipsTable: true + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + // ========================================== + // BASIC RENDERING + // ========================================== + describe('basic rendering', () => { + it('renders the component', () => { + wrapper = createWrapper() + expect(wrapper.exists()).toBe(true) + }) + + it('renders breadcrumb', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'BBreadcrumb' }).exists()).toBe(true) + }) + + it('renders ProjectCommandBar', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'ProjectCommandBar' }).exists()).toBe(true) + }) + + it('renders ProjectSidepanels', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'ProjectSidepanels' }).exists()).toBe(true) + }) + + it('renders tabs', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'BTabs' }).exists()).toBe(true) + }) + }) + + // ========================================== + // USESIDEBAR COMPOSABLE INTEGRATION + // ========================================== + describe('useSidebar composable integration', () => { + it('has activePanel in component state', () => { + wrapper = createWrapper() + expect(wrapper.vm.activePanel).toBeDefined() + }) + + it('togglePanel opens a panel', () => { + wrapper = createWrapper() + wrapper.vm.togglePanel('proj-details') + expect(wrapper.vm.activePanel).toBe('proj-details') + }) + + it('togglePanel closes panel when toggled again', () => { + wrapper = createWrapper() + wrapper.vm.togglePanel('proj-details') + wrapper.vm.togglePanel('proj-details') + expect(wrapper.vm.activePanel).toBeNull() + }) + + it('closePanel closes active panel', () => { + wrapper = createWrapper() + wrapper.vm.togglePanel('proj-details') + wrapper.vm.closePanel() + expect(wrapper.vm.activePanel).toBeNull() + }) + }) + + // ========================================== + // COMMAND BAR PROPS + // ========================================== + describe('command bar props', () => { + it('passes project to ProjectCommandBar', () => { + wrapper = createWrapper() + const commandBar = wrapper.findComponent({ name: 'ProjectCommandBar' }) + expect(commandBar.props('project')).toEqual(defaultProps.initialProjectState) + }) + + it('passes effective-permissions to ProjectCommandBar', () => { + wrapper = createWrapper() + const commandBar = wrapper.findComponent({ name: 'ProjectCommandBar' }) + expect(commandBar.props('effectivePermissions')).toBe('admin') + }) + + it('passes activePanel to ProjectCommandBar', async () => { + wrapper = createWrapper() + wrapper.vm.togglePanel('proj-details') + await wrapper.vm.$nextTick() + const commandBar = wrapper.findComponent({ name: 'ProjectCommandBar' }) + expect(commandBar.props('activePanel')).toBe('proj-details') + }) + }) + + // ========================================== + // SIDEPANELS PROPS + // ========================================== + describe('sidepanels props', () => { + it('passes project to ProjectSidepanels', () => { + wrapper = createWrapper() + const sidepanels = wrapper.findComponent({ name: 'ProjectSidepanels' }) + expect(sidepanels.props('project')).toEqual(defaultProps.initialProjectState) + }) + + it('passes activePanel to ProjectSidepanels', async () => { + wrapper = createWrapper() + wrapper.vm.togglePanel('proj-metadata') + await wrapper.vm.$nextTick() + const sidepanels = wrapper.findComponent({ name: 'ProjectSidepanels' }) + expect(sidepanels.props('activePanel')).toBe('proj-metadata') + }) + }) + + // ========================================== + // VISIBILITY TOGGLE + // ========================================== + describe('visibility toggle', () => { + it('showVisibilityConfirm sets pending visibility and shows modal', () => { + wrapper = createWrapper() + wrapper.vm.showVisibilityConfirm(true) + expect(wrapper.vm.pendingVisibility).toBe(true) + expect(wrapper.vm.showVisibilityModal).toBe(true) + }) + + it('updateVisibility closes modal and makes API call', async () => { + const axios = (await import('axios')).default + // Mock axios.get to return valid project structure for refreshProject + axios.get.mockResolvedValue({ data: defaultProps.initialProjectState }) + wrapper = createWrapper() + wrapper.vm.pendingVisibility = true + wrapper.vm.showVisibilityModal = true + + wrapper.vm.updateVisibility() + + expect(wrapper.vm.showVisibilityModal).toBe(false) + expect(axios.put).toHaveBeenCalledWith( + '/projects/1', + { project: { visibility: 'discoverable' } } + ) + }) + + it('cancelVisibilityChange closes modal and resets command bar toggle', () => { + wrapper = createWrapper() + wrapper.vm.showVisibilityModal = true + // Mock the command bar ref + const resetMock = vi.fn() + wrapper.vm.$refs.commandBar = { resetVisibilityToggle: resetMock } + + wrapper.vm.cancelVisibilityChange() + + expect(wrapper.vm.showVisibilityModal).toBe(false) + expect(resetMock).toHaveBeenCalled() + }) + + it('onVisibilityModalHidden resets command bar toggle (handles backdrop/escape)', () => { + wrapper = createWrapper() + // Mock the command bar ref + const resetMock = vi.fn() + wrapper.vm.$refs.commandBar = { resetVisibilityToggle: resetMock } + + wrapper.vm.onVisibilityModalHidden() + + expect(resetMock).toHaveBeenCalled() + }) + }) + + // ========================================== + // NEW COMPONENT MODAL + // ========================================== + describe('new component modal', () => { + it('openNewComponentModal calls the modal showModal method', () => { + wrapper = createWrapper() + // Mock the newComponentModal ref + const showModalMock = vi.fn() + wrapper.vm.$refs.newComponentModal = { showModal: showModalMock } + + wrapper.vm.openNewComponentModal() + + expect(showModalMock).toHaveBeenCalled() + }) + }) + + // ========================================== + // DOWNLOAD HANDLING (via ExportModal) + // ========================================== + describe('download handling', () => { + it('openExportModal shows the export modal', () => { + wrapper = createWrapper() + expect(wrapper.vm.showExportModal).toBe(false) + wrapper.vm.openExportModal() + expect(wrapper.vm.showExportModal).toBe(true) + }) + + it('executeExport calls downloadExport with type and componentIds from event', () => { + wrapper = createWrapper() + wrapper.vm.downloadExport = vi.fn() + + wrapper.vm.executeExport({ type: 'disa_excel', componentIds: [1, 2, 3] }) + + expect(wrapper.vm.downloadExport).toHaveBeenCalledWith('disa_excel', [1, 2, 3]) + }) + + it('executeExport works for all export types', () => { + const types = ['excel', 'disa_excel', 'inspec', 'xccdf'] + types.forEach(type => { + wrapper = createWrapper() + wrapper.vm.downloadExport = vi.fn() + + wrapper.vm.executeExport({ type, componentIds: [1] }) + + expect(wrapper.vm.downloadExport).toHaveBeenCalledWith(type, [1]) + wrapper.destroy() + }) + }) + + it('renders ExportModal component', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'ExportModal' }).exists()).toBe(true) + }) + }) + + // ========================================== + // NO RIGHT SIDEBAR (REGRESSION TEST) + // ========================================== + describe('layout - no right sidebar', () => { + it('does not have col-md-2 right sidebar', () => { + wrapper = createWrapper() + // The old layout had col-md-10 and col-md-2 + // With shallowMount, check for stubbed BCol with md="2" + const cols = wrapper.findAllComponents({ name: 'BCol' }) + const hasMd2 = cols.wrappers.some(col => col.attributes('md') === '2') + expect(hasMd2).toBe(false) + }) + + it('main content uses full width (md=12)', () => { + wrapper = createWrapper() + // Find BCol components and verify one has md="12" + const cols = wrapper.findAllComponents({ name: 'BCol' }) + const hasMd12 = cols.wrappers.some(col => col.attributes('md') === '12') + expect(hasMd12).toBe(true) + }) + }) +}) diff --git a/spec/javascript/components/project/ProjectCommandBar.spec.js b/spec/javascript/components/project/ProjectCommandBar.spec.js index 86c0ca805..f745b310d 100644 --- a/spec/javascript/components/project/ProjectCommandBar.spec.js +++ b/spec/javascript/components/project/ProjectCommandBar.spec.js @@ -75,7 +75,7 @@ describe('ProjectCommandBar', () => { wrapper = createWrapper() expect(wrapper.text()).toContain('Details') expect(wrapper.text()).toContain('Metadata') - expect(wrapper.text()).toContain('History') + expect(wrapper.text()).toContain('Activity') }) }) @@ -101,22 +101,53 @@ describe('ProjectCommandBar', () => { await checkbox.setChecked(true) expect(wrapper.emitted('toggle-visibility')).toBeTruthy() }) + + it('localVisibility syncs with project.visibility prop', async () => { + // Start with hidden project + wrapper = createWrapper({ + effectivePermissions: 'admin', + project: { id: 1, name: 'Test', visibility: 'hidden', components: [] } + }) + expect(wrapper.vm.localVisibility).toBe(false) + + // Change prop to discoverable + await wrapper.setProps({ + project: { id: 1, name: 'Test', visibility: 'discoverable', components: [] } + }) + expect(wrapper.vm.localVisibility).toBe(true) + }) + + it('resetVisibilityToggle resets local state to match prop', async () => { + wrapper = createWrapper({ + effectivePermissions: 'admin', + project: { id: 1, name: 'Test', visibility: 'hidden', components: [] } + }) + // Simulate user toggling to true + wrapper.vm.localVisibility = true + expect(wrapper.vm.localVisibility).toBe(true) + + // Reset should restore to match prop (hidden = false) + wrapper.vm.resetVisibilityToggle() + expect(wrapper.vm.localVisibility).toBe(false) + }) }) // ========================================== - // DOWNLOAD DROPDOWN + // DOWNLOAD BUTTON // ========================================== - describe('download dropdown', () => { - it('shows download dropdown', () => { + describe('download button', () => { + it('shows download button', () => { wrapper = createWrapper() - expect(wrapper.text()).toContain('Download') + const btn = wrapper.find('[data-testid="download-btn"]') + expect(btn.exists()).toBe(true) + expect(btn.text()).toContain('Download') }) - it('emits download event with type when option clicked', async () => { + it('emits download event when clicked', async () => { wrapper = createWrapper() - // Find and click dropdown item - const dropdown = wrapper.find('[data-testid="download-dropdown"]') - expect(dropdown.exists()).toBe(true) + const btn = wrapper.find('[data-testid="download-btn"]') + await btn.trigger('click') + expect(wrapper.emitted('download')).toBeTruthy() }) }) @@ -148,15 +179,17 @@ describe('ProjectCommandBar', () => { // PANEL BUTTONS // ========================================== describe('panel buttons', () => { - it('renders all 3 project panel buttons', () => { + it('renders all 4 panel buttons (Details, Metadata, Activity, Revisions)', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Details') + expect(wrapper.text()).toContain('Metadata') + expect(wrapper.text()).toContain('Activity') + expect(wrapper.text()).toContain('Revisions') + }) + + it('renders Members as action button (not panel button)', () => { wrapper = createWrapper() - const buttons = wrapper.findAll('button') - const panelButtons = buttons.wrappers.filter(b => - b.text().includes('Details') || - b.text().includes('Metadata') || - b.text().includes('History') - ) - expect(panelButtons.length).toBe(3) + expect(wrapper.text()).toContain('Members') }) it('emits toggle-panel with "proj-details" when Details clicked', async () => { @@ -175,13 +208,28 @@ describe('ProjectCommandBar', () => { expect(wrapper.emitted('toggle-panel')[0]).toEqual(['proj-metadata']) }) - it('emits toggle-panel with "proj-history" when History clicked', async () => { + it('emits toggle-panel with "proj-history" when Activity clicked', async () => { wrapper = createWrapper() - const btn = wrapper.findAll('button').wrappers.find(b => b.text().includes('History')) + const btn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Activity')) await btn.trigger('click') expect(wrapper.emitted('toggle-panel')).toBeTruthy() expect(wrapper.emitted('toggle-panel')[0]).toEqual(['proj-history']) }) + + it('emits toggle-panel with "proj-revision-history" when Revisions clicked', async () => { + wrapper = createWrapper() + const btn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Revisions')) + await btn.trigger('click') + expect(wrapper.emitted('toggle-panel')).toBeTruthy() + expect(wrapper.emitted('toggle-panel')[0]).toEqual(['proj-revision-history']) + }) + + it('emits open-members when Members clicked (opens modal, not panel)', async () => { + wrapper = createWrapper() + const btn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Members')) + await btn.trigger('click') + expect(wrapper.emitted('open-members')).toBeTruthy() + }) }) // ========================================== diff --git a/spec/javascript/components/shared/ExportModal.spec.js b/spec/javascript/components/shared/ExportModal.spec.js new file mode 100644 index 000000000..66e5ff65f --- /dev/null +++ b/spec/javascript/components/shared/ExportModal.spec.js @@ -0,0 +1,360 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mount, createLocalVue } from '@vue/test-utils' +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' +import ExportModal from '@/components/shared/ExportModal.vue' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) +localVue.use(IconsPlugin) + +/** + * ExportModal Component Tests + * + * REQUIREMENTS: + * + * 1. FORMAT SELECTION (Radio Buttons): + * - Shows all export formats: DISA Excel, Excel, InSpec, XCCDF + * - Each format has a brief description + * - Single selection via radio buttons + * - No format pre-selected by default + * + * 2. COMPONENT SELECTION: + * - Shows "All X components" checkbox + * - Individual component checkboxes below + * - "All" toggles all components + * - Indeterminate state when some selected + * - Single component: auto-selected, simplified view + * + * 3. EXPORT BUTTON: + * - Disabled when no format selected + * - Disabled when no components selected + * - Enabled only when BOTH format AND components selected + * + * 4. EMITS: + * - 'export': { type: string, componentIds: number[] } + * - 'cancel': user cancelled + * - 'update:visible': for v-model support + * + * 5. MODAL BEHAVIOR: + * - Cancel closes modal + * - Export closes modal after emitting + * - Backdrop/escape closes modal + */ +describe('ExportModal', () => { + let wrapper + + const singleComponent = [ + { id: 1, name: 'My Component', version: '1', release: '1' } + ] + + const multipleComponents = [ + { id: 1, name: 'Component A', version: '1', release: '1' }, + { id: 2, name: 'Component B', version: '2', release: '1' }, + { id: 3, name: 'Component C', version: '1', release: '2' } + ] + + const createWrapper = (props = {}) => { + return mount(ExportModal, { + localVue, + propsData: { + components: multipleComponents, + visible: true, + ...props + }, + stubs: { + 'b-modal': { + template: ` + + `, + props: ['visible', 'title', 'centered'], + methods: { + hide() { this.$emit('hidden') } + } + } + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + // ========================================== + // FORMAT SELECTION + // ========================================== + describe('format selection', () => { + it('renders all 4 export format options', () => { + wrapper = createWrapper() + const radios = wrapper.findAll('input[type="radio"]') + expect(radios.length).toBe(4) + }) + + it('shows DISA Excel option with description', () => { + wrapper = createWrapper() + const text = wrapper.text() + expect(text).toContain('DISA Excel') + expect(text).toContain('DoD/DISA format') + }) + + it('shows Excel option with description', () => { + wrapper = createWrapper() + const text = wrapper.text() + expect(text).toContain('Excel') + expect(text).toContain('Standard spreadsheet') + }) + + it('shows InSpec option with description', () => { + wrapper = createWrapper() + const text = wrapper.text() + expect(text).toContain('InSpec') + expect(text).toContain('Chef InSpec profile') + }) + + it('shows XCCDF option with description', () => { + wrapper = createWrapper() + const text = wrapper.text() + expect(text).toContain('XCCDF') + expect(text).toContain('SCAP XML format') + }) + + it('has no format selected by default', () => { + wrapper = createWrapper() + expect(wrapper.vm.selectedFormat).toBe(null) + }) + + it('updates selectedFormat when radio clicked', async () => { + wrapper = createWrapper() + const excelRadio = wrapper.find('input[value="excel"]') + await excelRadio.setChecked() + expect(wrapper.vm.selectedFormat).toBe('excel') + }) + }) + + // ========================================== + // COMPONENT SELECTION - MULTIPLE + // ========================================== + describe('component selection (multiple)', () => { + it('shows "All X components" checkbox', () => { + wrapper = createWrapper({ components: multipleComponents }) + expect(wrapper.text()).toContain('All 3 components') + }) + + it('shows individual component checkboxes', () => { + wrapper = createWrapper({ components: multipleComponents }) + expect(wrapper.text()).toContain('Component A') + expect(wrapper.text()).toContain('Component B') + expect(wrapper.text()).toContain('Component C') + }) + + it('has no components selected by default', () => { + wrapper = createWrapper({ components: multipleComponents }) + expect(wrapper.vm.selectedComponentIds.length).toBe(0) + }) + + it('checking "All" selects all components', async () => { + wrapper = createWrapper({ components: multipleComponents }) + const allCheckbox = wrapper.find('[data-testid="select-all"]') + await allCheckbox.find('input').setChecked(true) + expect(wrapper.vm.selectedComponentIds).toEqual([1, 2, 3]) + }) + + it('unchecking "All" deselects all components', async () => { + wrapper = createWrapper({ components: multipleComponents }) + // First select all + wrapper.vm.selectedComponentIds = [1, 2, 3] + await wrapper.vm.$nextTick() + // Then uncheck all + wrapper.vm.toggleSelectAll(false) + await wrapper.vm.$nextTick() + expect(wrapper.vm.selectedComponentIds).toEqual([]) + }) + + it('shows indeterminate state when some components selected', async () => { + wrapper = createWrapper({ components: multipleComponents }) + wrapper.vm.selectedComponentIds = [1] + await wrapper.vm.$nextTick() + expect(wrapper.vm.someSelected).toBe(true) + expect(wrapper.vm.allSelected).toBe(false) + }) + + it('allSelected is true when all components checked', async () => { + wrapper = createWrapper({ components: multipleComponents }) + wrapper.vm.selectedComponentIds = [1, 2, 3] + await wrapper.vm.$nextTick() + expect(wrapper.vm.allSelected).toBe(true) + }) + }) + + // ========================================== + // COMPONENT SELECTION - SINGLE + // ========================================== + describe('component selection (single)', () => { + it('auto-selects single component', () => { + wrapper = createWrapper({ components: singleComponent, visible: true }) + // Need to trigger the watch by simulating modal open + wrapper.vm.$options.watch.visible.call(wrapper.vm, true) + expect(wrapper.vm.selectedComponentIds).toEqual([1]) + }) + + it('shows simplified view for single component', () => { + wrapper = createWrapper({ components: singleComponent }) + // Should not show "All X components" checkbox for single + expect(wrapper.text()).not.toContain('All 1 components') + }) + + it('shows component name in single component mode', () => { + wrapper = createWrapper({ components: singleComponent }) + expect(wrapper.text()).toContain('My Component') + }) + }) + + // ========================================== + // EXPORT BUTTON STATE + // ========================================== + describe('export button state', () => { + it('is disabled when no format selected', () => { + wrapper = createWrapper({ components: multipleComponents }) + wrapper.vm.selectedComponentIds = [1, 2] + const exportBtn = wrapper.find('[data-testid="export-btn"]') + expect(exportBtn.attributes('disabled')).toBeDefined() + }) + + it('is disabled when no components selected', async () => { + wrapper = createWrapper({ components: multipleComponents }) + wrapper.vm.selectedFormat = 'excel' + await wrapper.vm.$nextTick() + const exportBtn = wrapper.find('[data-testid="export-btn"]') + expect(exportBtn.attributes('disabled')).toBeDefined() + }) + + it('is enabled when format AND components selected', async () => { + wrapper = createWrapper({ components: multipleComponents }) + wrapper.vm.selectedFormat = 'excel' + wrapper.vm.selectedComponentIds = [1] + await wrapper.vm.$nextTick() + const exportBtn = wrapper.find('[data-testid="export-btn"]') + expect(exportBtn.attributes('disabled')).toBeUndefined() + }) + }) + + // ========================================== + // EXPORT EVENT + // ========================================== + describe('export event', () => { + it('emits export with type and componentIds', async () => { + wrapper = createWrapper({ components: multipleComponents }) + wrapper.vm.selectedFormat = 'disa_excel' + wrapper.vm.selectedComponentIds = [1, 3] + await wrapper.vm.$nextTick() + + const exportBtn = wrapper.find('[data-testid="export-btn"]') + await exportBtn.trigger('click') + + expect(wrapper.emitted('export')).toBeTruthy() + expect(wrapper.emitted('export')[0]).toEqual([{ + type: 'disa_excel', + componentIds: [1, 3] + }]) + }) + + it('emits update:visible false after export', async () => { + wrapper = createWrapper({ components: multipleComponents }) + wrapper.vm.selectedFormat = 'excel' + wrapper.vm.selectedComponentIds = [1] + await wrapper.vm.$nextTick() + + const exportBtn = wrapper.find('[data-testid="export-btn"]') + await exportBtn.trigger('click') + + expect(wrapper.emitted('update:visible')).toBeTruthy() + expect(wrapper.emitted('update:visible')[0]).toEqual([false]) + }) + + it('works with single component auto-selection', async () => { + wrapper = createWrapper({ components: singleComponent, visible: true }) + wrapper.vm.$options.watch.visible.call(wrapper.vm, true) + wrapper.vm.selectedFormat = 'inspec' + await wrapper.vm.$nextTick() + + const exportBtn = wrapper.find('[data-testid="export-btn"]') + await exportBtn.trigger('click') + + expect(wrapper.emitted('export')[0]).toEqual([{ + type: 'inspec', + componentIds: [1] + }]) + }) + }) + + // ========================================== + // CANCEL + // ========================================== + describe('cancel', () => { + it('Cancel button emits cancel event', async () => { + wrapper = createWrapper() + const cancelBtn = wrapper.find('[data-testid="cancel-btn"]') + await cancelBtn.trigger('click') + expect(wrapper.emitted('cancel')).toBeTruthy() + }) + + it('Cancel button emits update:visible false', async () => { + wrapper = createWrapper() + const cancelBtn = wrapper.find('[data-testid="cancel-btn"]') + await cancelBtn.trigger('click') + expect(wrapper.emitted('update:visible')[0]).toEqual([false]) + }) + + it('onHidden emits cancel and closes modal', () => { + wrapper = createWrapper() + wrapper.vm.onHidden() + expect(wrapper.emitted('cancel')).toBeTruthy() + expect(wrapper.emitted('update:visible')[0]).toEqual([false]) + }) + }) + + // ========================================== + // MODAL TITLE + // ========================================== + describe('modal title', () => { + it('shows "Export Project" as default title', () => { + wrapper = createWrapper() + expect(wrapper.find('.modal-title').text()).toBe('Export Project') + }) + + it('uses custom title when provided', () => { + wrapper = createWrapper({ title: 'Download Components' }) + expect(wrapper.find('.modal-title').text()).toBe('Download Components') + }) + }) + + // ========================================== + // RESET ON OPEN + // ========================================== + describe('reset on open', () => { + it('resets format selection when modal opens', async () => { + wrapper = createWrapper({ visible: false }) + wrapper.vm.selectedFormat = 'excel' + await wrapper.setProps({ visible: true }) + expect(wrapper.vm.selectedFormat).toBe(null) + }) + + it('resets component selection when modal opens (multiple)', async () => { + wrapper = createWrapper({ components: multipleComponents, visible: false }) + wrapper.vm.selectedComponentIds = [1, 2] + await wrapper.setProps({ visible: true }) + expect(wrapper.vm.selectedComponentIds).toEqual([]) + }) + + it('auto-selects single component when modal opens', async () => { + wrapper = createWrapper({ components: singleComponent, visible: false }) + await wrapper.setProps({ visible: true }) + expect(wrapper.vm.selectedComponentIds).toEqual([1]) + }) + }) +}) From 03cfaa68e100deed3e6b71e0a1f1909b471f2a2b Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 4 Feb 2026 22:48:07 -0500 Subject: [PATCH 068/428] feat: Add reusable delete confirmation system Created reusable delete confirmation components to replace browser confirm dialogs with proper modals showing spinners during deletion. - Created ConfirmDeleteModal.vue component (configurable for any item type) - Created useDeleteConfirmation composable for state management - Added delete spinner to ComponentCard - Migrated ProjectsTable to use new confirmation system - Added memberships factory for tests - 52 new tests (ConfirmDeleteModal: 19, useDeleteConfirmation: 21, ProjectsTable: 13) Authored by: Aaron Lippold --- .../components/components/ComponentCard.vue | 17 +- .../components/projects/ProjectsTable.vue | 57 +++- .../components/shared/ConfirmDeleteModal.vue | 173 +++++++++++++ app/javascript/composables/index.js | 1 + .../composables/useDeleteConfirmation.js | 113 ++++++++ spec/factories/memberships.rb | 25 ++ .../components/projects/ProjectsTable.spec.js | 242 +++++++++++++++++ .../shared/ConfirmDeleteModal.spec.js | 199 ++++++++++++++ .../composables/useDeleteConfirmation.spec.js | 243 ++++++++++++++++++ 9 files changed, 1061 insertions(+), 9 deletions(-) create mode 100644 app/javascript/components/shared/ConfirmDeleteModal.vue create mode 100644 app/javascript/composables/useDeleteConfirmation.js create mode 100644 spec/factories/memberships.rb create mode 100644 spec/javascript/components/projects/ProjectsTable.spec.js create mode 100644 spec/javascript/components/shared/ConfirmDeleteModal.spec.js create mode 100644 spec/javascript/composables/useDeleteConfirmation.spec.js diff --git a/app/javascript/components/components/ComponentCard.vue b/app/javascript/components/components/ComponentCard.vue index 3d1702a77..8d29a1f15 100644 --- a/app/javascript/components/components/ComponentCard.vue +++ b/app/javascript/components/components/ComponentCard.vue @@ -1,13 +1,19 @@ @@ -123,6 +145,7 @@ import RoleComparisonMixin from "../../mixins/RoleComparisonMixin.vue"; import History from "../shared/History.vue"; import UpdateProjectDetailsModal from "../projects/UpdateProjectDetailsModal.vue"; import UpdateMetadataModal from "./UpdateMetadataModal.vue"; +import RevisionHistory from "./RevisionHistory.vue"; export default { name: "ProjectSidepanels", @@ -130,6 +153,7 @@ export default { History, UpdateProjectDetailsModal, UpdateMetadataModal, + RevisionHistory, }, mixins: [RoleComparisonMixin], props: { @@ -145,6 +169,10 @@ export default { type: String, default: null, }, + uniqueComponentNames: { + type: Array, + default: () => [], + }, }, computed: { isAdmin() { diff --git a/spec/javascript/components/project/ProjectSidepanels.spec.js b/spec/javascript/components/project/ProjectSidepanels.spec.js index 5c10a360f..92684f929 100644 --- a/spec/javascript/components/project/ProjectSidepanels.spec.js +++ b/spec/javascript/components/project/ProjectSidepanels.spec.js @@ -67,7 +67,8 @@ describe('ProjectSidepanels', () => { BSidebar: true, History: true, UpdateProjectDetailsModal: true, - UpdateMetadataModal: true + UpdateMetadataModal: true, + RevisionHistory: true } }) } @@ -87,10 +88,10 @@ describe('ProjectSidepanels', () => { expect(wrapper.exists()).toBe(true) }) - it('renders all 3 sidebars', () => { + it('renders all 4 sidebars (Details, Metadata, History, Revision History)', () => { wrapper = createWrapper() const sidebars = wrapper.findAllComponents({ name: 'BSidebar' }) - expect(sidebars.length).toBe(3) + expect(sidebars.length).toBe(4) }) }) @@ -116,14 +117,22 @@ describe('ProjectSidepanels', () => { expect(sidebar.attributes('visible')).toBe('true') }) + it('shows proj-revision-history sidebar when activePanel is proj-revision-history', () => { + wrapper = createWrapper({ activePanel: 'proj-revision-history' }) + const sidebar = wrapper.find('[data-testid="proj-revision-history-sidebar"]') + expect(sidebar.attributes('visible')).toBe('true') + }) + it('hides all sidebars when activePanel is null', () => { wrapper = createWrapper({ activePanel: null }) const detailsSidebar = wrapper.find('[data-testid="proj-details-sidebar"]') const metadataSidebar = wrapper.find('[data-testid="proj-metadata-sidebar"]') const historySidebar = wrapper.find('[data-testid="proj-history-sidebar"]') + const revisionSidebar = wrapper.find('[data-testid="proj-revision-history-sidebar"]') expect(detailsSidebar.attributes('visible')).toBeFalsy() expect(metadataSidebar.attributes('visible')).toBeFalsy() expect(historySidebar.attributes('visible')).toBeFalsy() + expect(revisionSidebar.attributes('visible')).toBeFalsy() }) }) @@ -195,4 +204,20 @@ describe('ProjectSidepanels', () => { expect(history.props('histories')).toEqual(defaultProps.project.histories) }) }) + + // ========================================== + // REVISION HISTORY PANEL + // ========================================== + describe('revision history panel', () => { + it('renders RevisionHistory component', () => { + wrapper = createWrapper({ activePanel: 'proj-revision-history' }) + expect(wrapper.findComponent({ name: 'RevisionHistory' }).exists()).toBe(true) + }) + + it('passes project to RevisionHistory', () => { + wrapper = createWrapper({ activePanel: 'proj-revision-history' }) + const revisionHistory = wrapper.findComponent({ name: 'RevisionHistory' }) + expect(revisionHistory.props('project')).toEqual(defaultProps.project) + }) + }) }) From c26dc5532a8c93b7705aced258c93a5fc3535f00 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 4 Feb 2026 22:48:48 -0500 Subject: [PATCH 071/428] refactor: Rename History to Activity and reorganize command bar buttons Improved clarity by renaming "History" to "Activity" to distinguish from "Revisions". Moved Members and Release buttons to right side for consistency. - Renamed "Comp History" to "Comp Activity" in terminology - Moved Members and Release buttons from left to right side - Left side now has only core action (Edit/View) - Right side has secondary actions and panels - Updated all related tests Authored by: Aaron Lippold --- .../components/shared/ControlsCommandBar.vue | 73 +++++++++++-------- app/javascript/constants/terminology.js | 4 +- .../shared/ControlsCommandBar.spec.js | 39 +++++++++- spec/javascript/constants/terminology.spec.js | 4 +- 4 files changed, 83 insertions(+), 37 deletions(-) diff --git a/app/javascript/components/shared/ControlsCommandBar.vue b/app/javascript/components/shared/ControlsCommandBar.vue index a09751cde..56dbd16b1 100644 --- a/app/javascript/components/shared/ControlsCommandBar.vue +++ b/app/javascript/components/shared/ControlsCommandBar.vue @@ -2,43 +2,52 @@
- +
- - - - Edit - - - - View - - - Members - - - Release - - + + + Edit + + + + View +
- +
+ + + Members + + + Release + + - + { }) }) - describe('Members button', () => { + describe('Members button (moved to right side)', () => { it('always shows Members button', () => { wrapper = createWrapper({ effectivePermissions: 'viewer' }) expect(wrapper.text()).toContain('Members') }) + it('Members button is on the right side (not in left action group)', () => { + wrapper = createWrapper() + // Members should NOT be in the left b-button-group (which contains Edit/View) + // This is a visual/layout test - implementation will move it to right side + expect(wrapper.text()).toContain('Members') + }) + it('emits open-members event when clicked', async () => { wrapper = createWrapper() const membersButton = wrapper.findAll('button').wrappers.find(b => b.text().includes('Members')) @@ -204,6 +211,36 @@ describe('ControlsCommandBar', () => { // Advanced Fields toggle moved to RuleEditor (per-component setting) + describe('Release button (moved to right side)', () => { + it('shows Release button for admin when releasable', () => { + wrapper = createWrapper({ + effectivePermissions: 'admin', + component: { ...defaultComponent, releasable: true, released: false } + }) + const releaseBtn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Release')) + expect(releaseBtn).toBeDefined() + }) + + it('disables Release button when not releasable', () => { + wrapper = createWrapper({ + effectivePermissions: 'admin', + component: { ...defaultComponent, releasable: false, released: false } + }) + const releaseBtn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Release')) + expect(releaseBtn.attributes('disabled')).toBeDefined() + }) + + it('emits release event when clicked', async () => { + wrapper = createWrapper({ + effectivePermissions: 'admin', + component: { ...defaultComponent, releasable: true, released: false } + }) + const releaseBtn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Release')) + await releaseBtn.trigger('click') + expect(wrapper.emitted('release')).toBeTruthy() + }) + }) + // ========================================== // COMPONENT PANELS // ========================================== diff --git a/spec/javascript/constants/terminology.spec.js b/spec/javascript/constants/terminology.spec.js index bbb46d640..f466348be 100644 --- a/spec/javascript/constants/terminology.spec.js +++ b/spec/javascript/constants/terminology.spec.js @@ -287,10 +287,10 @@ describe('terminology constants', () => { }) it('changing COMPONENT_TERM would update all derived labels', () => { - const expectedCompHistoryPattern = new RegExp(`${COMPONENT_TERM.label}.*History`) + const expectedCompActivityPattern = new RegExp(`${COMPONENT_TERM.label}.*Activity`) const expectedCompReviewsPattern = new RegExp(`${COMPONENT_TERM.label}.*Reviews`) - expect(PANEL_LABELS.compHistory).toMatch(expectedCompHistoryPattern) + expect(PANEL_LABELS.compHistory).toMatch(expectedCompActivityPattern) expect(PANEL_LABELS.compReviews).toMatch(expectedCompReviewsPattern) }) }) From 09b0e8de36a5e02176db41fd4a5c7e01c70362b8 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 4 Feb 2026 22:49:00 -0500 Subject: [PATCH 072/428] fix: Add memberships data to component JSON serialization for edit page MembersModal crashed on edit page because component JSON was missing both memberships association and inherited_memberships method. Added include and lazy loading to prevent render errors. - Added include: :memberships to serialize the association - Added inherited_memberships to methods list - Added lazy prop to modal to defer rendering until opened - Added safety checks for undefined inherited_memberships - Added regression tests for both data requirements Authored by: Aaron Lippold --- .../components/components/MembersModal.vue | 13 ++++--- app/views/rules/index.html.haml | 2 +- .../components/MembersModal.spec.js | 38 +++++++++++++++++++ spec/requests/rules_spec.rb | 30 +++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/app/javascript/components/components/MembersModal.vue b/app/javascript/components/components/MembersModal.vue index 7a84b833f..2c654e8e9 100644 --- a/app/javascript/components/components/MembersModal.vue +++ b/app/javascript/components/components/MembersModal.vue @@ -5,6 +5,7 @@ size="lg" centered scrollable + lazy ok-only ok-title="Close" body-class="p-0" @@ -81,11 +82,11 @@ - + @@ -211,7 +212,8 @@ export default { return "members-modal"; }, modalTitle() { - const count = this.component.memberships_count + this.component.inherited_memberships.length; + const inheritedCount = this.component.inherited_memberships?.length || 0; + const count = this.component.memberships_count + inheritedCount; return `Members (${count})`; }, isEditable() { @@ -227,11 +229,12 @@ export default { ); }, filteredInheritedMembers() { + const inherited = this.component.inherited_memberships || []; if (!this.inheritedSearch) { - return this.component.inherited_memberships; + return inherited; } const search = this.inheritedSearch.toLowerCase(); - return this.component.inherited_memberships.filter( + return inherited.filter( (m) => m.name.toLowerCase().includes(search) || m.email.toLowerCase().includes(search), ); }, diff --git a/app/views/rules/index.html.haml b/app/views/rules/index.html.haml index ccb5fec53..5f9c17505 100644 --- a/app/views/rules/index.html.haml +++ b/app/views/rules/index.html.haml @@ -5,7 +5,7 @@ #Rules %Rules{ | 'v-bind:project': @project.to_json, | - 'v-bind:component': @component.to_json(methods: %i[histories reviews metadata all_users]), | + 'v-bind:component': @component.to_json(include: :memberships, methods: %i[histories reviews metadata all_users inherited_memberships]), | 'v-bind:rules': @rules.to_json, | 'v-bind:statuses': RuleConstants::STATUSES.to_json, | 'v-bind:severities': RuleConstants::SEVERITIES.to_json, | diff --git a/spec/javascript/components/components/MembersModal.spec.js b/spec/javascript/components/components/MembersModal.spec.js index 3e762a751..6c01951d4 100644 --- a/spec/javascript/components/components/MembersModal.spec.js +++ b/spec/javascript/components/components/MembersModal.spec.js @@ -109,6 +109,25 @@ describe('MembersModal', () => { wrapper = createWrapper({ component: moreMembers }) expect(wrapper.vm.modalTitle).toBe('Members (7)') }) + + it('handles undefined inherited_memberships gracefully', () => { + const componentWithoutInherited = { + ...defaultProps.component, + inherited_memberships: undefined + } + wrapper = createWrapper({ component: componentWithoutInherited }) + // Should only count component members (2), inherited = 0 + expect(wrapper.vm.modalTitle).toBe('Members (2)') + }) + + it('handles null inherited_memberships gracefully', () => { + const componentWithNullInherited = { + ...defaultProps.component, + inherited_memberships: null + } + wrapper = createWrapper({ component: componentWithNullInherited }) + expect(wrapper.vm.modalTitle).toBe('Members (2)') + }) }) describe('permissions logic', () => { @@ -160,6 +179,25 @@ describe('MembersModal', () => { await wrapper.setData({ componentSearch: 'nonexistent' }) expect(wrapper.vm.filteredComponentMembers.length).toBe(0) }) + + it('handles undefined inherited_memberships in filter', () => { + const componentWithoutInherited = { + ...defaultProps.component, + inherited_memberships: undefined + } + wrapper = createWrapper({ component: componentWithoutInherited }) + // Should return empty array, not crash + expect(wrapper.vm.filteredInheritedMembers).toEqual([]) + }) + + it('handles null inherited_memberships in filter', () => { + const componentWithNullInherited = { + ...defaultProps.component, + inherited_memberships: null + } + wrapper = createWrapper({ component: componentWithNullInherited }) + expect(wrapper.vm.filteredInheritedMembers).toEqual([]) + }) }) describe('available members options', () => { diff --git a/spec/requests/rules_spec.rb b/spec/requests/rules_spec.rb index 46da0d7b9..7ff17c75a 100644 --- a/spec/requests/rules_spec.rb +++ b/spec/requests/rules_spec.rb @@ -81,6 +81,36 @@ # Verify the all_users key is present in the component JSON expect(response.body).to include('"all_users"') end + + it 'includes memberships association in component JSON for MembersModal display' do + # REQUIREMENT: MembersModal needs memberships association to show component-specific members + # Create a component-level membership + component_user = create(:user) + Membership.create!(user: component_user, membership: component, role: 'author') + + get "/components/#{component.id}/edit" + + expect(response).to have_http_status(:success) + # Verify the memberships array is present in the component JSON + expect(response.body).to include('"memberships"') + # Verify the component member is included + expect(response.body).to include(component_user.email) + end + + it 'includes inherited_memberships in component JSON for MembersModal display' do + # REQUIREMENT: MembersModal needs inherited_memberships to show project-level members + # Create a project-level membership (inherited by component) + other_user = create(:user) + Membership.create!(user: other_user, membership: project, role: 'viewer') + + get "/components/#{component.id}/edit" + + expect(response).to have_http_status(:success) + # Verify the inherited_memberships key is present in the component JSON + expect(response.body).to include('"inherited_memberships"') + # Verify the inherited member is included + expect(response.body).to include(other_user.email) + end end end From 7bc8b902a81b744590c49938c2744cc9c27d4207 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 4 Feb 2026 22:49:11 -0500 Subject: [PATCH 073/428] fix: Add missing lodash import to AlertMixin AlertMixin was using lodash's _.isPlainObject but missing the import, causing "_ is not defined" errors. Authored by: Aaron Lippold --- app/javascript/mixins/AlertMixin.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/mixins/AlertMixin.vue b/app/javascript/mixins/AlertMixin.vue index 2fb43d4cb..3242d0fd6 100644 --- a/app/javascript/mixins/AlertMixin.vue +++ b/app/javascript/mixins/AlertMixin.vue @@ -1,4 +1,6 @@ diff --git a/app/javascript/components/project/Project.vue b/app/javascript/components/project/Project.vue index 0bb6fadb8..c27b0663f 100644 --- a/app/javascript/components/project/Project.vue +++ b/app/javascript/components/project/Project.vue @@ -36,29 +36,9 @@

Project Components

-
- - - -
+

+ No components yet. Click New Component to get started. +

-

Overlaid Components

- +

Overlaid Components

+

+ No overlaid components. To add one, click + New ComponentAdd Overlaid Component. +

+ + + + + + + + + { + let wrapper + + const createWrapper = (props = {}) => { + return mount(ComponentActionPicker, { + localVue, + propsData: { + visible: true, + ...props + }, + stubs: { + 'b-modal': { + template: ` + + `, + props: ['visible', 'title', 'centered'] + } + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + // ========================================== + // OPTION RENDERING + // ========================================== + describe('option rendering', () => { + it('shows all 4 component action options', () => { + wrapper = createWrapper() + const radios = wrapper.findAll('input[type="radio"]') + expect(radios.length).toBe(4) + }) + + it('shows Create New option with description', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Create New Component') + expect(wrapper.text()).toContain('Start from scratch') + }) + + it('shows Import Spreadsheet option with description', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Import From Spreadsheet') + expect(wrapper.text()).toContain('Upload XLSX') + }) + + it('shows Copy Existing option with description', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Copy Existing Component') + expect(wrapper.text()).toContain('Duplicate from this project') + }) + + it('shows Add Overlay option with description', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Add Overlaid Component') + expect(wrapper.text()).toContain('Import released STIG') + }) + + it('has no option selected by default', () => { + wrapper = createWrapper() + expect(wrapper.vm.selectedAction).toBe(null) + }) + }) + + // ========================================== + // SELECTION BEHAVIOR + // ========================================== + describe('selection behavior', () => { + it('updates selectedAction when option clicked', async () => { + wrapper = createWrapper() + const createRadio = wrapper.find('input[value="create"]') + await createRadio.setChecked() + expect(wrapper.vm.selectedAction).toBe('create') + }) + + it('can select import option', async () => { + wrapper = createWrapper() + const importRadio = wrapper.find('input[value="import"]') + await importRadio.setChecked() + expect(wrapper.vm.selectedAction).toBe('import') + }) + + it('can select copy option', async () => { + wrapper = createWrapper() + const copyRadio = wrapper.find('input[value="copy"]') + await copyRadio.setChecked() + expect(wrapper.vm.selectedAction).toBe('copy') + }) + + it('can select overlay option', async () => { + wrapper = createWrapper() + const overlayRadio = wrapper.find('input[value="overlay"]') + await overlayRadio.setChecked() + expect(wrapper.vm.selectedAction).toBe('overlay') + }) + }) + + // ========================================== + // NEXT BUTTON + // ========================================== + describe('next button', () => { + it('is disabled when no option selected', () => { + wrapper = createWrapper() + const nextBtn = wrapper.find('[data-testid="next-btn"]') + expect(nextBtn.attributes('disabled')).toBeDefined() + }) + + it('is enabled when option selected', async () => { + wrapper = createWrapper() + wrapper.vm.selectedAction = 'create' + await wrapper.vm.$nextTick() + const nextBtn = wrapper.find('[data-testid="next-btn"]') + expect(nextBtn.attributes('disabled')).toBeUndefined() + }) + + it('emits next event with selected action type', async () => { + wrapper = createWrapper() + wrapper.vm.selectedAction = 'import' + await wrapper.vm.$nextTick() + + const nextBtn = wrapper.find('[data-testid="next-btn"]') + await nextBtn.trigger('click') + + expect(wrapper.emitted('next')).toBeTruthy() + expect(wrapper.emitted('next')[0]).toEqual(['import']) + }) + + it('closes modal after emitting next', async () => { + wrapper = createWrapper() + wrapper.vm.selectedAction = 'create' + await wrapper.vm.$nextTick() + + const nextBtn = wrapper.find('[data-testid="next-btn"]') + await nextBtn.trigger('click') + + expect(wrapper.emitted('update:visible')).toBeTruthy() + expect(wrapper.emitted('update:visible')[0]).toEqual([false]) + }) + }) + + // ========================================== + // CANCEL BUTTON + // ========================================== + describe('cancel button', () => { + it('emits cancel event', async () => { + wrapper = createWrapper() + const cancelBtn = wrapper.find('[data-testid="cancel-btn"]') + await cancelBtn.trigger('click') + expect(wrapper.emitted('cancel')).toBeTruthy() + }) + + it('closes modal', async () => { + wrapper = createWrapper() + const cancelBtn = wrapper.find('[data-testid="cancel-btn"]') + await cancelBtn.trigger('click') + expect(wrapper.emitted('update:visible')[0]).toEqual([false]) + }) + }) + + // ========================================== + // MODAL BEHAVIOR + // ========================================== + describe('modal behavior', () => { + it('resets selection when modal opens', async () => { + wrapper = createWrapper({ visible: false }) + wrapper.vm.selectedAction = 'create' + await wrapper.setProps({ visible: true }) + expect(wrapper.vm.selectedAction).toBe(null) + }) + + it('shows modal title', () => { + wrapper = createWrapper() + expect(wrapper.find('.modal-title').text()).toBe('Add Component') + }) + }) +}) diff --git a/spec/javascript/components/project/Project.spec.js b/spec/javascript/components/project/Project.spec.js index f462b3343..a1e986e0e 100644 --- a/spec/javascript/components/project/Project.spec.js +++ b/spec/javascript/components/project/Project.spec.js @@ -89,6 +89,7 @@ describe('Project', () => { ProjectCommandBar: true, ProjectSidepanels: true, ExportModal: true, + ComponentActionPicker: true, BBreadcrumb: true, BTabs: true, BTab: true, @@ -267,21 +268,126 @@ describe('Project', () => { }) // ========================================== - // NEW COMPONENT MODAL + // COMPONENT ACTION PICKER WORKFLOW (CONTRACT) // ========================================== - describe('new component modal', () => { - it('openNewComponentModal calls the modal showModal method', () => { + describe('component action picker workflow', () => { + it('renders ComponentActionPicker for selecting component creation method', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'ComponentActionPicker' }).exists()).toBe(true) + }) + + it('clicking New Component button shows ComponentActionPicker modal', () => { + wrapper = createWrapper() + expect(wrapper.vm.showComponentActionPicker).toBe(false) + wrapper.vm.openNewComponentModal() + expect(wrapper.vm.showComponentActionPicker).toBe(true) + }) + + it('routes "create" action to NewComponentModal (default mode)', () => { wrapper = createWrapper() - // Mock the newComponentModal ref const showModalMock = vi.fn() wrapper.vm.$refs.newComponentModal = { showModal: showModalMock } - wrapper.vm.openNewComponentModal() + wrapper.vm.handleComponentAction('create') + + expect(showModalMock).toHaveBeenCalled() + }) + + it('routes "import" action to NewComponentModal (spreadsheet mode)', () => { + wrapper = createWrapper() + const showModalMock = vi.fn() + wrapper.vm.$refs.importComponentModal = { showModal: showModalMock } + + wrapper.vm.handleComponentAction('import') + + expect(showModalMock).toHaveBeenCalled() + }) + + it('routes "copy" action to NewComponentModal (copy mode)', () => { + wrapper = createWrapper() + const showModalMock = vi.fn() + wrapper.vm.$refs.copyComponentModal = { showModal: showModalMock } + + wrapper.vm.handleComponentAction('copy') + + expect(showModalMock).toHaveBeenCalled() + }) + + it('routes "overlay" action to AddComponentModal', () => { + wrapper = createWrapper() + const showModalMock = vi.fn() + wrapper.vm.$refs.addComponentModal = { showModal: showModalMock } + + wrapper.vm.handleComponentAction('overlay') expect(showModalMock).toHaveBeenCalled() }) }) + // ========================================== + // EMPTY STATE MESSAGES + // ========================================== + describe('empty state messages', () => { + it('shows helpful message when no regular components', () => { + const emptyProject = { + ...defaultProps.initialProjectState, + components: [] + } + wrapper = createWrapper({ initialProjectState: emptyProject }) + expect(wrapper.text()).toContain('No components yet') + expect(wrapper.text()).toContain('New Component') + }) + + it('shows helpful message when no overlaid components', () => { + wrapper = createWrapper() + // Default project has no overlaid components + expect(wrapper.text()).toContain('No overlaid components') + expect(wrapper.text()).toContain('Add Overlaid Component') + }) + + it('does not show empty message when regular components exist', () => { + const projectWithComponents = { + ...defaultProps.initialProjectState, + components: [ + { id: 1, name: 'Test Component', component_id: null } + ] + } + wrapper = createWrapper({ initialProjectState: projectWithComponents }) + expect(wrapper.text()).not.toContain('No components yet') + }) + }) + + // ========================================== + // MODAL INSTANCES (NO OPENER BUTTONS) + // ========================================== + describe('modal instances without opener buttons', () => { + it('NewComponentModal instances exist for programmatic access', () => { + wrapper = createWrapper() + // Modals exist in template but don't render opener buttons (showOpener=false) + expect(wrapper.findAllComponents({ name: 'NewComponentModal' }).length).toBeGreaterThan(0) + }) + + it('AddComponentModal exists for programmatic access', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'AddComponentModal' }).exists()).toBe(true) + }) + + it('NewComponentModal does not pass showOpener prop (defaults to false = no button)', () => { + wrapper = createWrapper() + const modals = wrapper.findAllComponents({ name: 'NewComponentModal' }) + modals.wrappers.forEach(modal => { + // showOpener prop should not be set (defaults to false) + expect(modal.props('showOpener')).toBeFalsy() + }) + }) + + it('AddComponentModal does not pass showButton prop (defaults to false = no button)', () => { + wrapper = createWrapper() + const modal = wrapper.findComponent({ name: 'AddComponentModal' }) + expect(modal.props('showButton')).toBeFalsy() + }) + }) + // ========================================== // DOWNLOAD HANDLING (via ExportModal) // ========================================== From 1db57179de2c9b5a8df5637c0f550c2da3ca404a Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 01:11:14 -0500 Subject: [PATCH 075/428] refactor: Move Members button to modal actions group for better UX Repositioned Members button from right side (with panel toggles) to left side (with modal actions) for better semantic grouping and responsive behavior. - Members now grouped with New Component and Download (all open modals) - Panel toggles remain on right (Details, Metadata, Activity, Revisions) - Visibility toggle moved to end for better mobile stacking - Updated tests to reflect new layout Authored by: Aaron Lippold --- .../components/project/ProjectCommandBar.vue | 51 ++++++++++--------- .../project/ProjectCommandBar.spec.js | 16 ++++-- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/app/javascript/components/project/ProjectCommandBar.vue b/app/javascript/components/project/ProjectCommandBar.vue index 535aff322..30bf3e661 100644 --- a/app/javascript/components/project/ProjectCommandBar.vue +++ b/app/javascript/components/project/ProjectCommandBar.vue @@ -3,22 +3,6 @@
- -
- - {{ localVisibility ? 'Discoverable' : 'Hidden' }} - -
- Download + + + + Members + + + +
+ + {{ localVisibility ? 'Discoverable' : 'Hidden' }} + +
- +
Revisions - - Members -
diff --git a/spec/javascript/components/project/ProjectCommandBar.spec.js b/spec/javascript/components/project/ProjectCommandBar.spec.js index f745b310d..21555902e 100644 --- a/spec/javascript/components/project/ProjectCommandBar.spec.js +++ b/spec/javascript/components/project/ProjectCommandBar.spec.js @@ -152,10 +152,10 @@ describe('ProjectCommandBar', () => { }) // ========================================== - // NEW COMPONENT BUTTON (Admin only) + // MODAL ACTION BUTTONS (Left side: New Component, Download, Members) // ========================================== - describe('new component button', () => { - it('shows new component button for admin', () => { + describe('modal action buttons', () => { + it('shows new component button for admin (grouped with Download and Members)', () => { wrapper = createWrapper({ effectivePermissions: 'admin' }) const btn = wrapper.find('[data-testid="new-component-btn"]') expect(btn.exists()).toBe(true) @@ -167,6 +167,14 @@ describe('ProjectCommandBar', () => { expect(btn.exists()).toBe(false) }) + it('new component button is positioned before Download button (modal actions group)', () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + // New Component, Download, and Members should all be on left side + expect(wrapper.text()).toContain('New Component') + expect(wrapper.text()).toContain('Download') + expect(wrapper.text()).toContain('Members') + }) + it('emits new-component when clicked', async () => { wrapper = createWrapper({ effectivePermissions: 'admin' }) const btn = wrapper.find('[data-testid="new-component-btn"]') @@ -226,7 +234,7 @@ describe('ProjectCommandBar', () => { it('emits open-members when Members clicked (opens modal, not panel)', async () => { wrapper = createWrapper() - const btn = wrapper.findAll('button').wrappers.find(b => b.text().includes('Members')) + const btn = wrapper.find('[data-testid="members-btn"]') await btn.trigger('click') expect(wrapper.emitted('open-members')).toBeTruthy() }) From 2aa8a182b723d141c6c4fb6ca2106ee827e7d462 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 01:11:34 -0500 Subject: [PATCH 076/428] feat: Improve ComponentCard with labeled actions and remove export Replaced icon-only buttons with icon+text labels for clarity and consistency with panel buttons. Removed per-card export (use project Download instead). - Removed Export dropdown (CSV, InSpec, XCCDF) and downloadExport method - Changed action buttons to b-button-group with icon+text labels - Added Lock, Duplicate, Release, Delete text labels - Restored tooltips with detailed descriptions - Fixed Release tooltip to show on disabled state (wrapped in span) - Added size="sm" and font-scale="0.9" to match command bar - 25 ComponentCard tests, 7 NewComponentModal tests Authored by: Aaron Lippold --- .../components/components/ComponentCard.vue | 61 ++-- .../components/ComponentCard.spec.js | 282 ++++++++++++++++++ .../components/NewComponentModal.spec.js | 106 +++++++ 3 files changed, 407 insertions(+), 42 deletions(-) create mode 100644 spec/javascript/components/components/ComponentCard.spec.js create mode 100644 spec/javascript/components/components/NewComponentModal.spec.js diff --git a/app/javascript/components/components/ComponentCard.vue b/app/javascript/components/components/ComponentCard.vue index 8d29a1f15..504dce890 100644 --- a/app/javascript/components/components/ComponentCard.vue +++ b/app/javascript/components/components/ComponentCard.vue @@ -75,30 +75,16 @@ :href="`/components/${component.id}`" variant="primary" size="sm" - class="mr-2" > Open Component
- - - - - CSV - - - InSpec - - - XCCDF - -
-
+ - + Lock @@ -125,45 +110,47 @@ :predetermined_security_requirements_guide_id=" component.security_requirements_guide_id " + :show-opener="true" @projectUpdated="$emit('projectUpdated')" > - - - + + Release + + - + Delete -
+
@@ -228,16 +215,6 @@ export default { this.isDeleting = true; this.$emit("deleteComponent", this.component.id); }, - downloadExport: function (type) { - axios - .get(`/components/${this.component.id}/export/${type}`) - .then((_res) => { - // Once it is validated that there is content to download, prompt - // the user to save the file - window.open(`/components/${this.component.id}/export/${type}`); - }) - .catch(this.alertOrNotifyResponse); - }, }, }; diff --git a/spec/javascript/components/components/ComponentCard.spec.js b/spec/javascript/components/components/ComponentCard.spec.js new file mode 100644 index 000000000..e55b9d37f --- /dev/null +++ b/spec/javascript/components/components/ComponentCard.spec.js @@ -0,0 +1,282 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mount, createLocalVue } from '@vue/test-utils' +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' +import ComponentCard from '@/components/components/ComponentCard.vue' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) +localVue.use(IconsPlugin) + +// Mock axios +vi.mock('axios', () => ({ + default: { + defaults: { headers: { common: {} } } + } +})) + +/** + * ComponentCard Requirements + * + * REQUIREMENTS: + * + * 1. COMPONENT INFO DISPLAY: + * - Name and version/release + * - Based on SRG title + * - Description (if present) + * - PoC name and email + * - Rules count badge + * - Released badge (if released) + * + * 2. PRIMARY ACTION: + * - "Open Component" button (always visible) + * - Links to component view page + * + * 3. NO EXPORT DROPDOWN: + * - Export removed - users use project-level Download instead + * + * 4. ADMIN ACTIONS (icon buttons with tooltips): + * - Lock: Lock all rules in component (reviewer+) + * - Duplicate: Create copy of component (admin) + * - Release: Mark component as released (admin, only if releasable) + * - Delete: Remove from project (admin) + * + * 5. DELETE CONFIRMATION: + * - Shows overlay with confirmation + * - Shows spinner while deleting + * - Emits deleteComponent event + * + * 6. OVERLAID INDICATOR: + * - Shows "(Overlaid)" badge if component_id present + */ +describe('ComponentCard', () => { + let wrapper + + const defaultComponent = { + id: 1, + name: 'Test Component', + version: '1', + release: '1', + released: false, + releasable: true, + rules_count: 10, + component_id: null, + based_on_title: 'Test SRG', + based_on_version: 'V1R1', + description: 'Test description', + admin_name: 'Test Admin', + admin_email: 'admin@test.com', + project_id: 5 + } + + const createWrapper = (props = {}) => { + return mount(ComponentCard, { + localVue, + propsData: { + component: defaultComponent, + effectivePermissions: 'admin', + ...props + }, + stubs: { + LockControlsModal: true, + NewComponentModal: true + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + // ========================================== + // COMPONENT INFO DISPLAY + // ========================================== + describe('component information', () => { + it('displays component name', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Test Component') + }) + + it('displays version and release', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('v1') + expect(wrapper.text()).toContain('r1') + }) + + it('displays based on SRG', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Test SRG') + expect(wrapper.text()).toContain('V1R1') + }) + + it('displays description when present', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Test description') + }) + + it('displays PoC information', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('Test Admin') + expect(wrapper.text()).toContain('admin@test.com') + }) + + it('shows "No Component Admin" when admin not set', () => { + const compWithoutAdmin = { ...defaultComponent, admin_name: null, admin_email: null } + wrapper = createWrapper({ component: compWithoutAdmin }) + expect(wrapper.text()).toContain('No Component Admin') + }) + + it('displays rules count badge', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain('10') + }) + + it('shows overlaid indicator when component_id present', () => { + const overlaidComp = { ...defaultComponent, component_id: 999 } + wrapper = createWrapper({ component: overlaidComp }) + expect(wrapper.text()).toContain('Overlaid') + }) + + it('shows released indicator when component is released', () => { + const releasedComp = { ...defaultComponent, released: true } + wrapper = createWrapper({ component: releasedComp }) + // Patch-check-fill icon should exist when released + const html = wrapper.html() + expect(html).toContain('patch-check-fill') + }) + + it('does NOT show released indicator when component is not released', () => { + const unreleasedComp = { ...defaultComponent, released: false } + wrapper = createWrapper({ component: unreleasedComp }) + const html = wrapper.html() + expect(html).not.toContain('patch-check-fill') + }) + }) + + // ========================================== + // PRIMARY ACTION + // ========================================== + describe('open component button', () => { + it('renders Open Component button', () => { + wrapper = createWrapper() + const btn = wrapper.find('a[href="/components/1"]') + expect(btn.exists()).toBe(true) + expect(btn.text()).toContain('Open Component') + }) + }) + + // ========================================== + // EXPORT REMOVED + // ========================================== + describe('export functionality removed (use project Download)', () => { + it('does NOT render Export dropdown', () => { + wrapper = createWrapper() + expect(wrapper.text()).not.toMatch(/Export/i) + }) + + it('does NOT have CSV export option', () => { + wrapper = createWrapper() + expect(wrapper.text()).not.toContain('CSV') + }) + + it('does NOT have InSpec export option', () => { + wrapper = createWrapper() + expect(wrapper.text()).not.toContain('InSpec') + }) + + it('does NOT have XCCDF export option', () => { + wrapper = createWrapper() + expect(wrapper.text()).not.toContain('XCCDF') + }) + }) + + // ========================================== + // ADMIN ACTION BUTTONS (Icon + Text for consistency) + // ========================================== + describe('admin action buttons with labels', () => { + it('shows Lock button with icon and text for reviewer+', () => { + wrapper = createWrapper({ effectivePermissions: 'reviewer' }) + expect(wrapper.findComponent({ name: 'LockControlsModal' }).exists()).toBe(true) + // Button should have text label, not just tooltip + expect(wrapper.text()).toContain('Lock') + }) + + it('shows Duplicate button with icon and text for admin', () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + expect(wrapper.findAllComponents({ name: 'NewComponentModal' }).length).toBeGreaterThan(0) + expect(wrapper.text()).toContain('Duplicate') + }) + + it('shows Release button with icon and text for admin when releasable', () => { + wrapper = createWrapper({ + effectivePermissions: 'admin', + component: { ...defaultComponent, releasable: true, released: false } + }) + expect(wrapper.text()).toContain('Release') + }) + + it('shows Delete button with icon and text for admin', () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + expect(wrapper.text()).toContain('Delete') + }) + + it('disables Release button when not releasable', () => { + wrapper = createWrapper({ + effectivePermissions: 'admin', + component: { ...defaultComponent, releasable: false } + }) + const releaseBtn = wrapper.findAll('button').wrappers.find(b => + b.text().includes('Release') + ) + expect(releaseBtn.attributes('disabled')).toBeDefined() + }) + + it('hides admin actions for non-admin users', () => { + wrapper = createWrapper({ effectivePermissions: 'viewer', component: { ...defaultComponent, id: 1 } }) + // Should not show Delete button text + expect(wrapper.text()).not.toContain('Delete') + }) + + it('action buttons are in a button group for visual consistency', () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + // Actions should be in a button group like panel buttons + const buttonGroup = wrapper.find('.btn-group, .btn-toolbar') + expect(buttonGroup.exists()).toBe(true) + }) + }) + + // ========================================== + // DELETE CONFIRMATION + // ========================================== + describe('delete confirmation workflow', () => { + it('shows delete confirmation overlay when delete clicked', async () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + expect(wrapper.vm.showDeleteConfirmation).toBe(false) + + const deleteBtn = wrapper.findAll('button').wrappers.find(b => + b.text().includes('Delete') + ) + await deleteBtn.trigger('click') + + expect(wrapper.vm.showDeleteConfirmation).toBe(true) + }) + + it('shows spinner when delete is confirmed', async () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + wrapper.vm.showDeleteConfirmation = true + + wrapper.vm.confirmDelete() + + expect(wrapper.vm.isDeleting).toBe(true) + }) + + it('emits deleteComponent with component id when confirmed', () => { + wrapper = createWrapper({ effectivePermissions: 'admin' }) + wrapper.vm.confirmDelete() + + expect(wrapper.emitted('deleteComponent')).toBeTruthy() + expect(wrapper.emitted('deleteComponent')[0]).toEqual([1]) + }) + }) +}) diff --git a/spec/javascript/components/components/NewComponentModal.spec.js b/spec/javascript/components/components/NewComponentModal.spec.js new file mode 100644 index 000000000..fe8367997 --- /dev/null +++ b/spec/javascript/components/components/NewComponentModal.spec.js @@ -0,0 +1,106 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { shallowMount, createLocalVue } from '@vue/test-utils' +import { BootstrapVue } from 'bootstrap-vue' +import NewComponentModal from '@/components/components/NewComponentModal.vue' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) + +/** + * NewComponentModal showOpener Contract Tests + * + * REQUIREMENTS: + * + * 1. OPENER BUTTON RENDERING: + * - showOpener defaults to FALSE (no button renders) + * - showOpener=true renders the opener button + * - Prevents unwanted buttons when modal is triggered programmatically + * + * 2. PROGRAMMATIC ACCESS: + * - showModal() method exists for triggering via refs + * - Works regardless of showOpener value + */ +describe('NewComponentModal', () => { + let wrapper + + const defaultProps = { + project_id: 1, + project: { id: 1, name: 'Test Project' } + } + + const createWrapper = (props = {}) => { + return shallowMount(NewComponentModal, { + localVue, + propsData: { + ...defaultProps, + ...props + }, + mocks: { + $refs: { + AddComponentModal: { show: () => {} } + } + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + // ========================================== + // OPENER BUTTON CONTRACT + // ========================================== + describe('opener button rendering (regression prevention)', () => { + it('does NOT render opener button by default (showOpener defaults to false)', () => { + wrapper = createWrapper() + // With showOpener=false, the opener span should not render + const openerSpan = wrapper.find('span[v-if="showOpener"]') + // Since we're using shallowMount, check the prop value + expect(wrapper.props('showOpener')).toBe(false) + }) + + it('does NOT render opener button when showOpener explicitly false', () => { + wrapper = createWrapper({ showOpener: false }) + expect(wrapper.props('showOpener')).toBe(false) + }) + + it('DOES render opener button when showOpener=true', () => { + wrapper = createWrapper({ showOpener: true }) + expect(wrapper.props('showOpener')).toBe(true) + }) + }) + + // ========================================== + // PROGRAMMATIC ACCESS + // ========================================== + describe('programmatic modal triggering', () => { + it('has showModal method for programmatic access via refs', () => { + wrapper = createWrapper() + expect(typeof wrapper.vm.showModal).toBe('function') + }) + }) + + // ========================================== + // MODE PROPS + // ========================================== + describe('modal modes', () => { + it('default mode when no mode props set', () => { + wrapper = createWrapper() + expect(wrapper.props('spreadsheet_import')).toBe(false) + expect(wrapper.props('copy_component')).toBe(false) + }) + + it('spreadsheet import mode when prop set', () => { + wrapper = createWrapper({ spreadsheet_import: true }) + expect(wrapper.props('spreadsheet_import')).toBe(true) + }) + + it('copy component mode prop can be set', () => { + // Just verify the prop can be set - full functionality tested in integration + wrapper = createWrapper({ copy_component: true, project: { id: 1, name: 'Test', components: [] } }) + expect(wrapper.props('copy_component')).toBe(true) + }) + }) +}) From e34e82a81b87aedf07b9f4b1ea49d97addad24e7 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 02:31:52 -0500 Subject: [PATCH 077/428] feat: Extract BaseCommandBar for reusable command bar structure Extracted common wrapper structure from ControlsCommandBar and ProjectCommandBar into a reusable BaseCommandBar component with left/right/below slots. - Created BaseCommandBar.vue with pure presentation structure - Refactored ControlsCommandBar to use BaseCommandBar with slots - Refactored ProjectCommandBar to use BaseCommandBar with slots - Added below slot for content that appears on next line (Rule Context Bar) - Added mb-3 spacing between command bar and content below - 14 BaseCommandBar tests, zero risk refactoring Authored by: Aaron Lippold --- .../components/project/ProjectCommandBar.vue | 36 ++-- .../components/shared/BaseCommandBar.vue | 58 +++++++ .../components/shared/ControlsCommandBar.vue | 61 ++++--- .../components/shared/BaseCommandBar.spec.js | 163 ++++++++++++++++++ 4 files changed, 278 insertions(+), 40 deletions(-) create mode 100644 app/javascript/components/shared/BaseCommandBar.vue create mode 100644 spec/javascript/components/shared/BaseCommandBar.spec.js diff --git a/app/javascript/components/project/ProjectCommandBar.vue b/app/javascript/components/project/ProjectCommandBar.vue index 30bf3e661..cbfdee03a 100644 --- a/app/javascript/components/project/ProjectCommandBar.vue +++ b/app/javascript/components/project/ProjectCommandBar.vue @@ -1,8 +1,7 @@ + + diff --git a/app/javascript/components/shared/ControlsCommandBar.vue b/app/javascript/components/shared/ControlsCommandBar.vue index 56dbd16b1..4956482b3 100644 --- a/app/javascript/components/shared/ControlsCommandBar.vue +++ b/app/javascript/components/shared/ControlsCommandBar.vue @@ -1,14 +1,13 @@ diff --git a/app/javascript/components/projects/Projects.vue b/app/javascript/components/projects/Projects.vue index 2eb983cd9..ae2645790 100644 --- a/app/javascript/components/projects/Projects.vue +++ b/app/javascript/components/projects/Projects.vue @@ -1,21 +1,50 @@ - - diff --git a/app/javascript/packs/new_project.js b/app/javascript/packs/new_project.js deleted file mode 100644 index f14d2876b..000000000 --- a/app/javascript/packs/new_project.js +++ /dev/null @@ -1,16 +0,0 @@ -import TurbolinksAdapter from "vue-turbolinks"; -import Vue from "vue"; -import NewProject from "../components/project/NewProject.vue"; -import { BootstrapVue, IconsPlugin } from "bootstrap-vue"; - -Vue.use(TurbolinksAdapter); -Vue.use(BootstrapVue); -Vue.use(IconsPlugin); - -Vue.component("Newproject", NewProject); - -document.addEventListener("turbolinks:load", () => { - new Vue({ - el: "#NewProject", - }); -}); diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml deleted file mode 100644 index 2520c310d..000000000 --- a/app/views/projects/new.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- content_for :assets do - = javascript_include_tag 'new_project' - -# = stylesheet_link_tag 'new_project' - -#NewProject - %NewProject{} diff --git a/config/routes.rb b/config/routes.rb index 47bb611cb..1d83cbb93 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,7 +19,7 @@ resources :stigs, only: %i[index show create destroy] resources :memberships, only: %i[create update destroy] - resources :projects do + resources :projects, except: [:new] do resources :components, only: %i[show create update destroy], shallow: true do post 'lock', to: 'reviews#lock_controls' resources :rules, only: %i[index show create update destroy], shallow: true do diff --git a/esbuild.config.js b/esbuild.config.js index 68e3b8cf5..7a6e6216b 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -18,7 +18,6 @@ const entryPoints = { stig: "app/javascript/packs/stig.js", stigs: "app/javascript/packs/stigs.js", users: "app/javascript/packs/users.js", - new_project: "app/javascript/packs/new_project.js", }; // Check if we're in watch mode From c5784a9fc382f3e12e48bce4d1dead9cad2f8ae2 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 11:52:07 -0500 Subject: [PATCH 081/428] feat: Standardize Released Components with breadcrumb and Download Added breadcrumb and BaseCommandBar to Released Components page. Users can now export released components via Download button using ExportModal. - Added breadcrumb: "Released Components" - Added BaseCommandBar with Download button - Integrated ExportModal for format and component selection - Search and filter functionality preserved - 14 tests Authored by: Aaron Lippold --- .../components/ProjectComponents.vue | 56 +++++- .../components/ProjectComponents.spec.js | 185 ++++++++++++++++++ 2 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 spec/javascript/components/components/ProjectComponents.spec.js diff --git a/app/javascript/components/components/ProjectComponents.vue b/app/javascript/components/components/ProjectComponents.vue index 267d686ae..9d5fae5c5 100644 --- a/app/javascript/components/components/ProjectComponents.vue +++ b/app/javascript/components/components/ProjectComponents.vue @@ -1,6 +1,23 @@ From d5ee9db0309998a7fbb1a39e1a2c97626e25ca85 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 11:53:20 -0500 Subject: [PATCH 084/428] feat: Convert User Profile to Vue with breadcrumb and comprehensive features Converted Devise profile edit page from Rails form to Vue component with modern interface and all available functionality. - Created UserProfile.vue with breadcrumb and BaseCommandBar - Command bar: Save Profile (left), My Activity panel, Delete Account (right) - Profile form: Name, Email, Slack User ID, Password fields - Auth provider badge: Shows Local/GitHub/OIDC/LDAP for all users - Email confirmation alert: Warns if email pending confirmation - My Activity panel: Shows user's audit history with empty state - Delete account: Uses ConfirmDeleteModal with axios - Auto-focus on validation errors (e.g., missing current password) - Registrations controller: JSON responses for update and destroy - Controller edit action: Loads user audit history for activity panel - 17 comprehensive tests Authored by: Aaron Lippold --- .../users/registrations_controller.rb | 58 ++++ .../components/users/UserProfile.vue | 280 ++++++++++++++++++ app/javascript/packs/user_profile.js | 16 + app/views/devise/registrations/edit.html.haml | 64 +--- esbuild.config.js | 1 + .../components/users/UserProfile.spec.js | 227 ++++++++++++++ 6 files changed, 590 insertions(+), 56 deletions(-) create mode 100644 app/javascript/components/users/UserProfile.vue create mode 100644 app/javascript/packs/user_profile.js create mode 100644 spec/javascript/components/users/UserProfile.spec.js diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 29a3c5657..231a9a5b0 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -14,6 +14,64 @@ def create end end + def edit + # Load user's audit history for the activity panel + @histories = Audited.audit_class.includes(:user) + .where(user_id: current_user.id) + .order(created_at: :desc) + .limit(50) + .map(&:format) + super + end + + def update + self.resource = resource_class.to_adapter.get!(send(:"current_#{resource_name}").to_key) + prev_unconfirmed_email = resource.unconfirmed_email if resource.respond_to?(:unconfirmed_email) + + resource_updated = update_resource(resource, account_update_params) + + if resource_updated + respond_to do |format| + format.html do + bypass_sign_in resource, scope: resource_name if sign_in_after_change_password? + flash[:notice] = 'Profile updated successfully.' + redirect_to after_update_path_for(resource) + end + format.json { render json: { toast: 'Profile updated successfully.' } } + end + else + respond_to do |format| + format.html do + clean_up_passwords resource + set_minimum_password_length + respond_with resource + end + format.json do + render json: { + toast: { + title: 'Could not update profile.', + message: resource.errors.full_messages, + variant: 'danger' + } + }, status: :unprocessable_entity + end + end + end + end + + def destroy + resource.destroy + Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name) + + respond_to do |format| + format.html do + flash[:notice] = 'Your account has been successfully deleted.' + redirect_to root_path + end + format.json { render json: { toast: 'Account deleted successfully.' } } + end + end + protected def update_resource(resource, params) diff --git a/app/javascript/components/users/UserProfile.vue b/app/javascript/components/users/UserProfile.vue new file mode 100644 index 000000000..b1b2a4e09 --- /dev/null +++ b/app/javascript/components/users/UserProfile.vue @@ -0,0 +1,280 @@ + + + diff --git a/app/javascript/packs/user_profile.js b/app/javascript/packs/user_profile.js new file mode 100644 index 000000000..eba0ba488 --- /dev/null +++ b/app/javascript/packs/user_profile.js @@ -0,0 +1,16 @@ +import TurbolinksAdapter from "vue-turbolinks"; +import Vue from "vue"; +import { BootstrapVue, IconsPlugin } from "bootstrap-vue"; +import UserProfile from "../components/users/UserProfile.vue"; + +Vue.use(TurbolinksAdapter); +Vue.use(BootstrapVue); +Vue.use(IconsPlugin); + +Vue.component("userprofile", UserProfile); + +document.addEventListener("turbolinks:load", () => { + new Vue({ + el: "#user-profile", + }); +}); diff --git a/app/views/devise/registrations/edit.html.haml b/app/views/devise/registrations/edit.html.haml index 927bf3831..b6118a62e 100644 --- a/app/views/devise/registrations/edit.html.haml +++ b/app/views/devise/registrations/edit.html.haml @@ -1,56 +1,8 @@ -%h2 - Edit #{resource_name.to_s.humanize} -- unless resource.provider.nil? - #providerHelp.form-text.text-muted="Some settings are managed by #{resource.provider} and cannot be changed here." - %br/ - -= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| - = render "devise/shared/error_messages", resource: resource - .form-group - = f.label :your_name - %br/ - = f.text_field :name, autofocus: true, class: "form-control", autocomplete: "name", required: "true", disabled: !resource.provider.nil? - .form-group - = f.label :email - %br/ - = f.email_field :email, autocomplete: "email", class: "form-control", disabled: !resource.provider.nil? - .form-group - = f.label :slack_user_ID - %br/ - = f.text_field :slack_user_id, autofocus: true, class: "form-control", autocomplete: "slack_user_id", disabled: !resource.provider.nil? - %small#passwordHelp.form-text.text-muted - Provide your slack's user ID (e.g. U123456) if you would like to receive slack notifications - - - if resource.provider.nil? - - if devise_mapping.confirmable? && resource.pending_reconfirmation? - %div - Currently waiting confirmation for: #{resource.unconfirmed_email} - .form-group - - = f.label :password - %i (leave blank if you don't want to change it) - - = f.password_field :password, autocomplete: "new-password", class: "form-control", disabled: !resource.provider.nil? - - if @minimum_password_length - %small#passwordHelp.form-text.text-muted - = @minimum_password_length - characters minimum - - - .form-group - = f.label :password_confirmation - %br/ - = f.password_field :password_confirmation, autocomplete: "new-password", class: "form-control", disabled: !resource.provider.nil? - .form-group - = f.label :current_password - %i (we need your current password to confirm your changes) - %br/ - = f.password_field :current_password, autocomplete: "current-password", class: "form-control" - .actions - = f.submit "Update", class: 'btn btn-success btn-block' - %br/ - -%h3 Cancel my account -%p - Unhappy? #{button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete, class: "btn btn-danger"} -= link_to "Back", :back, class: "btn btn-light" +- content_for :assets do + = javascript_include_tag 'user_profile' + +#user-profile + %userprofile{ | + 'v-bind:user': current_user.to_json, | + 'v-bind:histories': (@histories || []).to_json | + } diff --git a/esbuild.config.js b/esbuild.config.js index 7a6e6216b..a60ab9095 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -18,6 +18,7 @@ const entryPoints = { stig: "app/javascript/packs/stig.js", stigs: "app/javascript/packs/stigs.js", users: "app/javascript/packs/users.js", + user_profile: "app/javascript/packs/user_profile.js", }; // Check if we're in watch mode diff --git a/spec/javascript/components/users/UserProfile.spec.js b/spec/javascript/components/users/UserProfile.spec.js new file mode 100644 index 000000000..474131289 --- /dev/null +++ b/spec/javascript/components/users/UserProfile.spec.js @@ -0,0 +1,227 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { shallowMount, createLocalVue } from '@vue/test-utils' +import { BootstrapVue } from 'bootstrap-vue' +import UserProfile from '@/components/users/UserProfile.vue' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) + +// Mock axios +vi.mock('axios', () => ({ + default: { + put: vi.fn(() => Promise.resolve({ data: { toast: 'Updated' } })), + defaults: { headers: { common: {} } } + } +})) + +/** + * UserProfile Component Requirements + * + * REQUIREMENTS: + * + * 1. BREADCRUMB: + * - Shows "Users / Profile" or just "Profile" + * + * 2. COMMAND BAR: + * - Uses BaseCommandBar + * - LEFT: Save button + * - RIGHT: Empty or panel for help/info + * + * 3. PROFILE FORM: + * - Name (editable unless provider managed) + * - Email (editable unless provider managed) + * - Slack User ID (optional) + * - Password fields (only for local auth) + * - Current password required for changes + * + * 4. PROVIDER NOTICE: + * - Shows notice if managed by external provider (GitHub, OIDC, LDAP) + * - Disables fields that can't be changed + * + * 5. SAVE: + * - Uses axios PUT to /users + * - Shows success/error toast + * - Handles validation errors + */ +describe('UserProfile', () => { + let wrapper + + const defaultProps = { + user: { + id: 1, + name: 'Test User', + email: 'test@example.com', + provider: null, + slack_user_id: '', + unconfirmed_email: null + }, + histories: [ + { id: 1, user_id: 1, action: 'update', auditable_type: 'User' }, + { id: 2, user_id: 2, action: 'create', auditable_type: 'Project' } + ] + } + + const createWrapper = (props = {}) => { + return shallowMount(UserProfile, { + localVue, + propsData: { + ...defaultProps, + ...props + }, + stubs: { + BBreadcrumb: true, + BaseCommandBar: true + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + describe('breadcrumb', () => { + it('renders breadcrumb', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'BBreadcrumb' }).exists()).toBe(true) + }) + + it('shows Profile breadcrumb', () => { + wrapper = createWrapper() + expect(wrapper.vm.breadcrumbs).toBeDefined() + expect(wrapper.vm.breadcrumbs.some(b => b.text.includes('Profile'))).toBe(true) + }) + }) + + describe('command bar', () => { + it('renders BaseCommandBar', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'BaseCommandBar' }).exists()).toBe(true) + }) + + it('has Save button in command bar', () => { + wrapper = createWrapper() + expect(wrapper.vm.saveProfile).toBeDefined() + }) + }) + + describe('form fields', () => { + it('initializes form with user data', () => { + wrapper = createWrapper() + expect(wrapper.vm.form.name).toBe('Test User') + expect(wrapper.vm.form.email).toBe('test@example.com') + expect(wrapper.vm.form.slack_user_id).toBe('') + }) + + it('includes password fields for local auth', () => { + wrapper = createWrapper({ user: { ...defaultProps.user, provider: null } }) + // Form should have password fields + expect(wrapper.vm.form.password).toBeDefined() + expect(wrapper.vm.form.password_confirmation).toBeDefined() + expect(wrapper.vm.form.current_password).toBeDefined() + }) + }) + + describe('provider managed', () => { + it('detects provider managed users', () => { + wrapper = createWrapper({ user: { ...defaultProps.user, provider: 'github' } }) + expect(wrapper.vm.isProviderManaged).toBe(true) + }) + + it('detects local auth users', () => { + wrapper = createWrapper({ user: { ...defaultProps.user, provider: null } }) + expect(wrapper.vm.isProviderManaged).toBe(false) + }) + }) + + describe('save profile', () => { + it('calls axios.put with form data', async () => { + const axios = (await import('axios')).default + wrapper = createWrapper() + wrapper.vm.form.name = 'Updated Name' + + await wrapper.vm.saveProfile() + + expect(axios.put).toHaveBeenCalled() + }) + + it('shows success message on save', async () => { + wrapper = createWrapper() + wrapper.vm.form.name = 'Updated Name' + + await wrapper.vm.saveProfile() + + // Should emit success or show toast + expect(wrapper.vm.saving).toBe(false) + }) + + it('focuses current password field on validation error', async () => { + const axios = (await import('axios')).default + axios.put.mockRejectedValue({ + response: { + data: { + toast: { + message: ["Current password can't be blank"] + } + } + } + }) + + wrapper = createWrapper() + await wrapper.vm.saveProfile() + + // Component should try to focus the field + expect(wrapper.vm.saving).toBe(false) + }) + }) + + describe('email confirmation', () => { + it('shows pending confirmation alert when email unconfirmed', () => { + wrapper = createWrapper({ + user: { ...defaultProps.user, unconfirmed_email: 'new@example.com' } + }) + expect(wrapper.vm.isPendingConfirmation).toBe(true) + }) + + it('does not show alert when email confirmed', () => { + wrapper = createWrapper({ + user: { ...defaultProps.user, unconfirmed_email: null } + }) + expect(wrapper.vm.isPendingConfirmation).toBe(false) + }) + }) + + describe('user activity panel', () => { + it('filters histories to show only current user actions', () => { + wrapper = createWrapper() + const filtered = wrapper.vm.userHistories + expect(filtered.length).toBe(1) + expect(filtered[0].user_id).toBe(1) + }) + + it('has togglePanel method from useSidebar', () => { + wrapper = createWrapper() + expect(typeof wrapper.vm.togglePanel).toBe('function') + }) + }) + + describe('delete account', () => { + it('openDeleteAccount shows confirmation modal', () => { + wrapper = createWrapper() + expect(wrapper.vm.showDeleteModal).toBe(false) + wrapper.vm.openDeleteAccount() + expect(wrapper.vm.showDeleteModal).toBe(true) + }) + + it('confirmDeleteAccount calls axios.delete', async () => { + const axios = (await import('axios')).default + axios.delete = vi.fn(() => Promise.resolve({})) + + wrapper = createWrapper() + await wrapper.vm.confirmDeleteAccount() + + expect(axios.delete).toHaveBeenCalledWith('/users') + }) + }) +}) From a805af2e2017dd109085275b2b0e36f87943dba4 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 14:18:53 -0500 Subject: [PATCH 085/428] feat: Add benchmark adapter layer with ident parsing Created adapter functions to normalize STIG and SRG data structures into unified format, and utilities to parse security control references. - stigToBenchmark: Normalizes STIG (stig_rules, benchmark_date) to unified format - srgToBenchmark: Normalizes SRG (srg_rules, release_date) to unified format - parseMitreAttack: Extracts MITRE ATT&CK technique IDs from ident strings - parseCisControls: Extracts CIS Control IDs from ident strings - 19 adapter tests, 19 ident parser tests (38 total) Authored by: Aaron Lippold --- app/javascript/adapters/benchmark.js | 44 +++++ app/javascript/utils/identParser.js | 58 +++++++ spec/javascript/adapters/benchmark.spec.js | 191 +++++++++++++++++++++ spec/javascript/utils/identParser.spec.js | 133 ++++++++++++++ 4 files changed, 426 insertions(+) create mode 100644 app/javascript/adapters/benchmark.js create mode 100644 app/javascript/utils/identParser.js create mode 100644 spec/javascript/adapters/benchmark.spec.js create mode 100644 spec/javascript/utils/identParser.spec.js diff --git a/app/javascript/adapters/benchmark.js b/app/javascript/adapters/benchmark.js new file mode 100644 index 000000000..fda731bd0 --- /dev/null +++ b/app/javascript/adapters/benchmark.js @@ -0,0 +1,44 @@ +/** + * Benchmark Adapters + * + * Normalize STIG and SRG data into a unified structure for BenchmarkViewer. + * This allows shared components to work with both types without knowing + * the structural differences. + * + * Pattern from v2.3.0: Adapter functions at the component boundary transform + * DB-specific structures into a common interface. + */ + +/** + * Normalize STIG data to unified benchmark structure + * + * @param {Object} stig - STIG object from Rails API + * @returns {Object} Normalized benchmark object + */ +export function stigToBenchmark(stig) { + return { + id: stig.id, + benchmark_id: stig.stig_id, + title: stig.title, + version: stig.version, + date: stig.benchmark_date, + rules: stig.stig_rules || [], + }; +} + +/** + * Normalize SRG data to unified benchmark structure + * + * @param {Object} srg - SRG object from Rails API + * @returns {Object} Normalized benchmark object + */ +export function srgToBenchmark(srg) { + return { + id: srg.id, + benchmark_id: srg.srg_id, + title: srg.title, + version: srg.version, + date: srg.release_date, + rules: srg.srg_rules || [], + }; +} diff --git a/app/javascript/utils/identParser.js b/app/javascript/utils/identParser.js new file mode 100644 index 000000000..21bad8061 --- /dev/null +++ b/app/javascript/utils/identParser.js @@ -0,0 +1,58 @@ +/** + * Ident Parser Utilities + * + * Parse security control references from rule ident fields. + * Rules often reference MITRE ATT&CK techniques and CIS Controls + * in comma-separated ident fields. + */ + +/** + * Parse MITRE ATT&CK technique IDs from ident string + * + * Extracts technique IDs in format: T####, T####.### + * Examples: "T1078", "T1078.004" + * + * @param {string|null|undefined} ident - Comma-separated ident string + * @returns {string[]} Array of MITRE technique IDs + */ +export function parseMitreAttack(ident) { + if (!ident) return []; + + // Match MITRE ATT&CK technique pattern: T followed by 4 digits, optionally .### for subtechnique + const mitrePattern = /T\d{4}(?:\.\d{3})?/g; + const matches = ident.match(mitrePattern); + + return matches || []; +} + +/** + * Parse CIS Control IDs from ident string + * + * Extracts control IDs in formats: + * - Single digit: "18" + * - Major.Minor: "5.2", "6.1" + * - Major.Minor.Sub: "5.2.1" + * + * @param {string|null|undefined} ident - Comma-separated ident string + * @returns {string[]} Array of CIS Control IDs + */ +export function parseCisControls(ident) { + if (!ident) return []; + + // Match CIS Control pattern: Number, Number.Number, or Number.Number.Number + // Extracts standalone numbers or decimal numbers from comma-separated list + const cisPattern = /\b\d{1,2}(?:\.\d{1,2}(?:\.\d{1,2})?)?\b/g; + const matches = ident.match(cisPattern); + + if (!matches) return []; + + // Filter out results that are clearly not CIS controls + // CIS controls are typically: 1-18 (v7) or with decimals like 5.2 + // Exclude things that look like years (2024), long numbers, etc. + return matches.filter(match => { + const num = parseFloat(match); + // CIS v7 has controls 1-18, v8 has 1-18 as well + // Accept single/double digit numbers and decimals in that range + return num >= 1 && num <= 99 && match.length <= 6; + }); +} diff --git a/spec/javascript/adapters/benchmark.spec.js b/spec/javascript/adapters/benchmark.spec.js new file mode 100644 index 000000000..8d224086f --- /dev/null +++ b/spec/javascript/adapters/benchmark.spec.js @@ -0,0 +1,191 @@ +import { describe, it, expect } from 'vitest' +import { stigToBenchmark, srgToBenchmark } from '@/adapters/benchmark' + +/** + * Benchmark Adapter Tests + * + * REQUIREMENTS: + * + * Adapters normalize STIG and SRG data into a unified structure so + * BenchmarkViewer can work with both without knowing the differences. + * + * UNIFIED STRUCTURE: + * { + * id: number, + * benchmark_id: string, // stig_id or srg_id + * title: string, + * version: string, + * date: string, // benchmark_date or release_date + * rules: array // stig_rules or srg_rules + * } + */ +describe('Benchmark Adapters', () => { + // ========================================== + // STIG TO BENCHMARK + // ========================================== + describe('stigToBenchmark', () => { + const sampleStig = { + id: 1, + stig_id: 'TEST_STIG', + title: 'Test STIG', + version: 'V1R1', + benchmark_date: '2024-01-15', + stig_rules: [ + { id: 1, rule_id: 'SV-001', title: 'Rule One' }, + { id: 2, rule_id: 'SV-002', title: 'Rule Two' } + ] + } + + it('preserves id', () => { + const result = stigToBenchmark(sampleStig) + expect(result.id).toBe(1) + }) + + it('normalizes stig_id to benchmark_id', () => { + const result = stigToBenchmark(sampleStig) + expect(result.benchmark_id).toBe('TEST_STIG') + }) + + it('preserves title', () => { + const result = stigToBenchmark(sampleStig) + expect(result.title).toBe('Test STIG') + }) + + it('preserves version', () => { + const result = stigToBenchmark(sampleStig) + expect(result.version).toBe('V1R1') + }) + + it('normalizes benchmark_date to date', () => { + const result = stigToBenchmark(sampleStig) + expect(result.date).toBe('2024-01-15') + }) + + it('normalizes stig_rules to rules', () => { + const result = stigToBenchmark(sampleStig) + expect(result.rules).toHaveLength(2) + expect(result.rules[0].rule_id).toBe('SV-001') + expect(result.rules[1].rule_id).toBe('SV-002') + }) + + it('handles missing stig_rules gracefully', () => { + const stigWithoutRules = { ...sampleStig, stig_rules: undefined } + const result = stigToBenchmark(stigWithoutRules) + expect(result.rules).toEqual([]) + }) + + it('handles null stig_rules gracefully', () => { + const stigWithNull = { ...sampleStig, stig_rules: null } + const result = stigToBenchmark(stigWithNull) + expect(result.rules).toEqual([]) + }) + + it('handles empty stig_rules array', () => { + const stigEmpty = { ...sampleStig, stig_rules: [] } + const result = stigToBenchmark(stigEmpty) + expect(result.rules).toEqual([]) + }) + }) + + // ========================================== + // SRG TO BENCHMARK + // ========================================== + describe('srgToBenchmark', () => { + const sampleSrg = { + id: 2, + srg_id: 'TEST_SRG', + title: 'Test SRG', + version: 'V2R1', + release_date: '2024-02-20', + srg_rules: [ + { id: 10, rule_id: 'SRG-001', title: 'Requirement One' }, + { id: 11, rule_id: 'SRG-002', title: 'Requirement Two' } + ] + } + + it('preserves id', () => { + const result = srgToBenchmark(sampleSrg) + expect(result.id).toBe(2) + }) + + it('normalizes srg_id to benchmark_id', () => { + const result = srgToBenchmark(sampleSrg) + expect(result.benchmark_id).toBe('TEST_SRG') + }) + + it('preserves title', () => { + const result = srgToBenchmark(sampleSrg) + expect(result.title).toBe('Test SRG') + }) + + it('preserves version', () => { + const result = srgToBenchmark(sampleSrg) + expect(result.version).toBe('V2R1') + }) + + it('normalizes release_date to date', () => { + const result = srgToBenchmark(sampleSrg) + expect(result.date).toBe('2024-02-20') + }) + + it('normalizes srg_rules to rules', () => { + const result = srgToBenchmark(sampleSrg) + expect(result.rules).toHaveLength(2) + expect(result.rules[0].rule_id).toBe('SRG-001') + expect(result.rules[1].rule_id).toBe('SRG-002') + }) + + it('handles missing srg_rules gracefully', () => { + const srgWithoutRules = { ...sampleSrg, srg_rules: undefined } + const result = srgToBenchmark(srgWithoutRules) + expect(result.rules).toEqual([]) + }) + + it('handles null srg_rules gracefully', () => { + const srgWithNull = { ...sampleSrg, srg_rules: null } + const result = srgToBenchmark(srgWithNull) + expect(result.rules).toEqual([]) + }) + + it('handles empty srg_rules array', () => { + const srgEmpty = { ...sampleSrg, srg_rules: [] } + const result = srgToBenchmark(srgEmpty) + expect(result.rules).toEqual([]) + }) + }) + + // ========================================== + // UNIFIED STRUCTURE VALIDATION + // ========================================== + describe('unified structure', () => { + it('STIG and SRG adapters produce identical structure', () => { + const stig = { + id: 1, + stig_id: 'TEST', + title: 'Test', + version: 'V1R1', + benchmark_date: '2024-01-01', + stig_rules: [] + } + + const srg = { + id: 1, + srg_id: 'TEST', + title: 'Test', + version: 'V1R1', + release_date: '2024-01-01', + srg_rules: [] + } + + const stigResult = stigToBenchmark(stig) + const srgResult = srgToBenchmark(srg) + + // Both should have same structure + expect(Object.keys(stigResult).sort()).toEqual(Object.keys(srgResult).sort()) + expect(stigResult.benchmark_id).toBe(srgResult.benchmark_id) + expect(stigResult.date).toBe(srgResult.date) + expect(Array.isArray(stigResult.rules)).toBe(true) + expect(Array.isArray(srgResult.rules)).toBe(true) + }) + }) +}) diff --git a/spec/javascript/utils/identParser.spec.js b/spec/javascript/utils/identParser.spec.js new file mode 100644 index 000000000..1ab2c46b8 --- /dev/null +++ b/spec/javascript/utils/identParser.spec.js @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest' +import { parseMitreAttack, parseCisControls } from '@/utils/identParser' + +/** + * Ident Parser Tests + * + * REQUIREMENTS: + * + * Rules have ident and ident_system fields that contain references to: + * - MITRE ATT&CK techniques (e.g., "T1078", "T1078.004") + * - CIS Controls (e.g., "5.2", "6.1", "18") + * + * Parsers extract and structure this data for display in RuleOverview. + */ +describe('Ident Parser', () => { + // ========================================== + // MITRE ATT&CK PARSER + // ========================================== + describe('parseMitreAttack', () => { + it('parses single MITRE technique', () => { + const ident = 'T1078' + const result = parseMitreAttack(ident) + expect(result).toEqual(['T1078']) + }) + + it('parses MITRE technique with subtechnique', () => { + const ident = 'T1078.004' + const result = parseMitreAttack(ident) + expect(result).toEqual(['T1078.004']) + }) + + it('parses comma-separated techniques', () => { + const ident = 'T1078, T1548, T1068' + const result = parseMitreAttack(ident) + expect(result).toEqual(['T1078', 'T1548', 'T1068']) + }) + + it('handles techniques with whitespace', () => { + const ident = 'T1078 , T1548 , T1068' + const result = parseMitreAttack(ident) + expect(result).toEqual(['T1078', 'T1548', 'T1068']) + }) + + it('filters out non-MITRE content', () => { + const ident = 'T1078, CCI-000123, T1548' + const result = parseMitreAttack(ident) + expect(result).toEqual(['T1078', 'T1548']) + }) + + it('returns empty array for null ident', () => { + const result = parseMitreAttack(null) + expect(result).toEqual([]) + }) + + it('returns empty array for undefined ident', () => { + const result = parseMitreAttack(undefined) + expect(result).toEqual([]) + }) + + it('returns empty array for empty string', () => { + const result = parseMitreAttack('') + expect(result).toEqual([]) + }) + + it('returns empty array when no MITRE techniques found', () => { + const ident = 'CCI-000123, CCI-000456' + const result = parseMitreAttack(ident) + expect(result).toEqual([]) + }) + }) + + // ========================================== + // CIS CONTROLS PARSER + // ========================================== + describe('parseCisControls', () => { + it('parses single CIS control', () => { + const ident = '5.2' + const result = parseCisControls(ident) + expect(result).toEqual(['5.2']) + }) + + it('parses single-digit CIS control', () => { + const ident = '18' + const result = parseCisControls(ident) + expect(result).toEqual(['18']) + }) + + it('parses comma-separated CIS controls', () => { + const ident = '5.2, 6.1, 18' + const result = parseCisControls(ident) + expect(result).toEqual(['5.2', '6.1', '18']) + }) + + it('handles controls with whitespace', () => { + const ident = '5.2 , 6.1 , 18' + const result = parseCisControls(ident) + expect(result).toEqual(['5.2', '6.1', '18']) + }) + + it('parses controls with three-digit decimals', () => { + const ident = '5.2.1, 6.1.2' + const result = parseCisControls(ident) + expect(result).toEqual(['5.2.1', '6.1.2']) + }) + + it('filters out non-CIS content', () => { + const ident = '5.2, T1078, 6.1' + const result = parseCisControls(ident) + expect(result).toEqual(['5.2', '6.1']) + }) + + it('returns empty array for null ident', () => { + const result = parseCisControls(null) + expect(result).toEqual([]) + }) + + it('returns empty array for undefined ident', () => { + const result = parseCisControls(undefined) + expect(result).toEqual([]) + }) + + it('returns empty array for empty string', () => { + const result = parseCisControls('') + expect(result).toEqual([]) + }) + + it('returns empty array when no CIS controls found', () => { + const ident = 'T1078, CCI-000123' + const result = parseCisControls(ident) + expect(result).toEqual([]) + }) + }) +}) From 3be803d2c2cf2846330facd7804de4d326c2bea6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 14:19:18 -0500 Subject: [PATCH 086/428] feat: Add useBenchmarkViewer composable for unified navigation Created composable for type-agnostic benchmark navigation and filtering. Configuration-driven to handle STIG, SRG, and CIS benchmarks. - State: selectedItem, items, filteredItems, searchTerm - Navigation: selectItem, selectNext, selectPrevious - Filtering: setSearch (searches across configured fields) - Config-driven: itemsKey, searchFields adapt per type - 19 tests covering all functionality Authored by: Aaron Lippold --- app/javascript/composables/index.js | 1 + .../composables/useBenchmarkViewer.js | 151 +++++++++++++++ .../composables/useBenchmarkViewer.spec.js | 183 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 app/javascript/composables/useBenchmarkViewer.js create mode 100644 spec/javascript/composables/useBenchmarkViewer.spec.js diff --git a/app/javascript/composables/index.js b/app/javascript/composables/index.js index 0ec01a36e..f8b5e8ca5 100644 --- a/app/javascript/composables/index.js +++ b/app/javascript/composables/index.js @@ -9,3 +9,4 @@ export { useSidebar, panelNames } from "./useSidebar"; export { useRuleActions } from "./useRuleActions"; export { useSearch } from "./useSearch"; export { useDeleteConfirmation } from "./useDeleteConfirmation"; +export { useBenchmarkViewer } from "./useBenchmarkViewer"; diff --git a/app/javascript/composables/useBenchmarkViewer.js b/app/javascript/composables/useBenchmarkViewer.js new file mode 100644 index 000000000..f2493091e --- /dev/null +++ b/app/javascript/composables/useBenchmarkViewer.js @@ -0,0 +1,151 @@ +import { ref, computed } from "vue"; + +/** + * Benchmark Type Configurations + * + * Defines how to adapt the viewer for different benchmark types. + */ +const BENCHMARK_CONFIG = { + stig: { + itemTypeName: 'rule', + itemsKey: 'stig_rules', + searchFields: ['rule_id', 'title', 'severity'], + idField: 'rule_id', + }, + srg: { + itemTypeName: 'requirement', + itemsKey: 'requirements', + searchFields: ['req_id', 'title'], + idField: 'req_id', + }, + cis: { + itemTypeName: 'control', + itemsKey: 'stig_rules', // CIS stored as STIG in DB + searchFields: ['rule_id', 'title', 'level'], + idField: 'rule_id', + }, +}; + +/** + * useBenchmarkViewer - Unified viewer for STIG/SRG/CIS benchmarks + * + * Provides type-agnostic state management and navigation for benchmark viewers. + * Configuration-driven to handle differences between benchmark types. + * + * Usage: + * import { useBenchmarkViewer } from "@/composables/useBenchmarkViewer"; + * + * setup() { + * const { + * selectedItem, + * items, + * filteredItems, + * selectItem, + * selectNext, + * selectPrevious, + * setSearch + * } = useBenchmarkViewer(benchmark, 'stig'); + * + * return { selectedItem, filteredItems, selectItem, setSearch }; + * } + * + * @param {Object} benchmarkData - The benchmark object (STIG, SRG, or CIS) + * @param {String} type - Benchmark type ('stig' | 'srg' | 'cis') + * @returns {Object} Reactive state and methods + */ +export function useBenchmarkViewer(benchmarkData, type) { + // Get configuration for this benchmark type + const config = BENCHMARK_CONFIG[type]; + if (!config) { + throw new Error(`Unknown benchmark type: ${type}. Must be 'stig', 'srg', or 'cis'.`); + } + + // State + const benchmark = ref(benchmarkData); + const benchmarkType = ref(type); + const searchTerm = ref(''); + + // Extract items from benchmark using config + const items = computed(() => { + return benchmark.value[config.itemsKey] || []; + }); + + // Selected item state + const selectedItem = ref(items.value[0] || null); + + // Filtered items based on search + const filteredItems = computed(() => { + if (!searchTerm.value) return items.value; + + const search = searchTerm.value.toLowerCase(); + return items.value.filter(item => { + // Search across configured fields + return config.searchFields.some(field => { + const value = item[field]; + return value && String(value).toLowerCase().includes(search); + }); + }); + }); + + // Item type name from config + const itemTypeName = computed(() => config.itemTypeName); + + /** + * Select a specific item + */ + function selectItem(item) { + selectedItem.value = item; + } + + /** + * Navigate to next item in filtered list + */ + function selectNext() { + const currentIndex = filteredItems.value.findIndex( + item => item.id === selectedItem.value?.id + ); + const nextIndex = (currentIndex + 1) % filteredItems.value.length; + selectedItem.value = filteredItems.value[nextIndex]; + } + + /** + * Navigate to previous item in filtered list + */ + function selectPrevious() { + const currentIndex = filteredItems.value.findIndex( + item => item.id === selectedItem.value?.id + ); + const prevIndex = currentIndex <= 0 + ? filteredItems.value.length - 1 + : currentIndex - 1; + selectedItem.value = filteredItems.value[prevIndex]; + } + + /** + * Set search term to filter items + */ + function setSearch(term) { + searchTerm.value = term; + // If current selected item is filtered out, select first filtered item + if (filteredItems.value.length > 0 && + !filteredItems.value.find(item => item.id === selectedItem.value?.id)) { + selectedItem.value = filteredItems.value[0]; + } + } + + return { + // State + benchmark, + benchmarkType, + selectedItem, + items, + filteredItems, + searchTerm, + itemTypeName, + // Methods + selectItem, + selectNext, + selectPrevious, + setSearch, + }; +} diff --git a/spec/javascript/composables/useBenchmarkViewer.spec.js b/spec/javascript/composables/useBenchmarkViewer.spec.js new file mode 100644 index 000000000..401d97f5e --- /dev/null +++ b/spec/javascript/composables/useBenchmarkViewer.spec.js @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useBenchmarkViewer } from '@/composables/useBenchmarkViewer' + +/** + * useBenchmarkViewer Composable Tests + * + * REQUIREMENTS: + * + * 1. TYPE-AGNOSTIC STATE: + * - selectedItem: Currently selected rule/requirement/control + * - items: List of all items from benchmark + * - filteredItems: Filtered/searched items + * - searchTerm: Current search query + * + * 2. NAVIGATION: + * - selectItem(item): Set selected item + * - selectNext(): Navigate to next item + * - selectPrevious(): Navigate to previous item + * + * 3. FILTERING: + * - setSearch(term): Filter items by search term + * - Searches across configured fields based on type + * + * 4. TYPE CONFIGURATION: + * - Accepts type ('stig' | 'srg' | 'cis') + * - Uses config to adapt to benchmark structure + * - Config defines: itemsKey, searchFields, displayFields + * + * 5. REUSABLE: + * - Works for STIG, SRG, CIS without code changes + * - Configuration-driven adaptation + */ +describe('useBenchmarkViewer', () => { + let composable + + const stigBenchmark = { + id: 1, + title: 'Test STIG', + version: 'V1R1', + stig_rules: [ + { id: 1, rule_id: 'SV-001', title: 'Rule One', severity: 'high' }, + { id: 2, rule_id: 'SV-002', title: 'Rule Two', severity: 'medium' }, + { id: 3, rule_id: 'SV-003', title: 'Another Rule', severity: 'low' } + ] + } + + const srgBenchmark = { + id: 1, + title: 'Test SRG', + version: 'V2R1', + requirements: [ + { id: 1, req_id: 'SRG-001', title: 'Requirement One' }, + { id: 2, req_id: 'SRG-002', title: 'Requirement Two' } + ] + } + + beforeEach(() => { + composable = useBenchmarkViewer(stigBenchmark, 'stig') + }) + + // ========================================== + // INITIAL STATE + // ========================================== + describe('initial state', () => { + it('initializes with benchmark data', () => { + expect(composable.benchmark.value).toEqual(stigBenchmark) + }) + + it('extracts items from benchmark based on type config', () => { + expect(composable.items.value.length).toBe(3) + expect(composable.items.value).toEqual(stigBenchmark.stig_rules) + }) + + it('selects first item by default', () => { + expect(composable.selectedItem.value).toEqual(stigBenchmark.stig_rules[0]) + }) + + it('searchTerm starts empty', () => { + expect(composable.searchTerm.value).toBe('') + }) + + it('filteredItems equals all items when no search', () => { + expect(composable.filteredItems.value.length).toBe(3) + }) + }) + + // ========================================== + // ITEM SELECTION + // ========================================== + describe('item selection', () => { + it('selectItem sets the selected item', () => { + const secondItem = stigBenchmark.stig_rules[1] + composable.selectItem(secondItem) + expect(composable.selectedItem.value).toEqual(secondItem) + }) + + it('selectNext moves to next item', () => { + expect(composable.selectedItem.value.id).toBe(1) + composable.selectNext() + expect(composable.selectedItem.value.id).toBe(2) + }) + + it('selectNext wraps to first item at end', () => { + composable.selectItem(stigBenchmark.stig_rules[2]) // Last item + composable.selectNext() + expect(composable.selectedItem.value.id).toBe(1) // Wraps to first + }) + + it('selectPrevious moves to previous item', () => { + composable.selectItem(stigBenchmark.stig_rules[1]) // Second item + composable.selectPrevious() + expect(composable.selectedItem.value.id).toBe(1) // First item + }) + + it('selectPrevious wraps to last item at start', () => { + composable.selectItem(stigBenchmark.stig_rules[0]) // First item + composable.selectPrevious() + expect(composable.selectedItem.value.id).toBe(3) // Wraps to last + }) + }) + + // ========================================== + // SEARCH/FILTERING + // ========================================== + describe('search and filtering', () => { + it('setSearch updates searchTerm', () => { + composable.setSearch('rule') + expect(composable.searchTerm.value).toBe('rule') + }) + + it('filters items based on search term', () => { + composable.setSearch('Rule One') + expect(composable.filteredItems.value.length).toBe(1) + expect(composable.filteredItems.value[0].title).toBe('Rule One') + }) + + it('search is case-insensitive', () => { + composable.setSearch('RULE ONE') + expect(composable.filteredItems.value.length).toBe(1) + }) + + it('searches across multiple fields (title, rule_id)', () => { + composable.setSearch('SV-002') + expect(composable.filteredItems.value.length).toBe(1) + expect(composable.filteredItems.value[0].rule_id).toBe('SV-002') + }) + + it('clears search shows all items', () => { + composable.setSearch('Rule One') + expect(composable.filteredItems.value.length).toBe(1) + composable.setSearch('') + expect(composable.filteredItems.value.length).toBe(3) + }) + }) + + // ========================================== + // TYPE CONFIGURATION + // ========================================== + describe('type-specific configuration', () => { + it('works with STIG type', () => { + composable = useBenchmarkViewer(stigBenchmark, 'stig') + expect(composable.items.value).toEqual(stigBenchmark.stig_rules) + }) + + it('works with SRG type', () => { + composable = useBenchmarkViewer(srgBenchmark, 'srg') + expect(composable.items.value).toEqual(srgBenchmark.requirements) + }) + + it('provides type info', () => { + composable = useBenchmarkViewer(stigBenchmark, 'stig') + expect(composable.benchmarkType.value).toBe('stig') + }) + + it('provides item type name from config', () => { + composable = useBenchmarkViewer(stigBenchmark, 'stig') + expect(composable.itemTypeName.value).toBe('rule') + + composable = useBenchmarkViewer(srgBenchmark, 'srg') + expect(composable.itemTypeName.value).toBe('requirement') + }) + }) +}) From 44b461a66396f8fc26287770bae69387cadea957 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 14:19:45 -0500 Subject: [PATCH 087/428] feat: Add unified BenchmarkViewer and enable SRG detail pages Created unified BenchmarkViewer component that works for STIGs, SRGs, and CIS benchmarks. Enabled SRG detail/explorer pages for the first time. - BenchmarkViewer.vue: Generic 3-column viewer with breadcrumb + command bar - Stig.vue: Simplified to use BenchmarkViewer - SRG controller: Added show action with srg_rules serialization - SRG show view: Created (renders BenchmarkViewer with type='srg') - SecurityRequirementsGuidesTable: Fixed SRG links (slot name + field mismatch) - Routes: Added :show to srgs resources - STIG show view: Simplified props (just benchmark + type) - 12 BenchmarkViewer tests SRGs now viewable/explorable like STIGs! Authored by: Aaron Lippold --- ...security_requirements_guides_controller.rb | 12 +- .../SecurityRequirementsGuidesTable.vue | 3 + .../components/shared/BenchmarkViewer.vue | 164 ++++++++++++++++++ app/javascript/components/stigs/Stig.vue | 48 +---- .../show.html.haml | 9 + app/views/stigs/show.html.haml | 7 +- config/routes.rb | 2 +- .../components/shared/BenchmarkViewer.spec.js | 153 ++++++++++++++++ 8 files changed, 346 insertions(+), 52 deletions(-) create mode 100644 app/javascript/components/shared/BenchmarkViewer.vue create mode 100644 app/views/security_requirements_guides/show.html.haml create mode 100644 spec/javascript/components/shared/BenchmarkViewer.spec.js diff --git a/app/controllers/security_requirements_guides_controller.rb b/app/controllers/security_requirements_guides_controller.rb index b2fe6eba8..43e0337cf 100644 --- a/app/controllers/security_requirements_guides_controller.rb +++ b/app/controllers/security_requirements_guides_controller.rb @@ -2,8 +2,8 @@ # Controller for SecurityRequirementsGuides class SecurityRequirementsGuidesController < ApplicationController - before_action :authorize_admin, except: %i[index] - before_action :security_requirements_guide, only: %i[destroy] + before_action :authorize_admin, except: %i[index show] + before_action :security_requirements_guide, only: %i[show destroy] def index @srgs = SecurityRequirementsGuide.order(:srg_id, :version).select(:id, :srg_id, :title, :version, :release_date) @@ -13,6 +13,14 @@ def index end end + def show + @srg_json = @srg.to_json(methods: %i[srg_rules]) + respond_to do |format| + format.html + format.json { render json: @srg_json } + end + end + def create file = params.require('file') parsed_benchmark = Xccdf::Benchmark.parse(file.read) diff --git a/app/javascript/components/security_requirements_guides/SecurityRequirementsGuidesTable.vue b/app/javascript/components/security_requirements_guides/SecurityRequirementsGuidesTable.vue index 47d98ad70..df9f4f297 100644 --- a/app/javascript/components/security_requirements_guides/SecurityRequirementsGuidesTable.vue +++ b/app/javascript/components/security_requirements_guides/SecurityRequirementsGuidesTable.vue @@ -30,6 +30,9 @@ + + + diff --git a/app/javascript/components/stigs/Stig.vue b/app/javascript/components/stigs/Stig.vue index f08aeb487..81557a41f 100644 --- a/app/javascript/components/stigs/Stig.vue +++ b/app/javascript/components/stigs/Stig.vue @@ -1,58 +1,18 @@ - - diff --git a/app/views/security_requirements_guides/show.html.haml b/app/views/security_requirements_guides/show.html.haml new file mode 100644 index 000000000..952b09c1e --- /dev/null +++ b/app/views/security_requirements_guides/show.html.haml @@ -0,0 +1,9 @@ +- content_for :assets do + = javascript_include_tag 'stig' + = stylesheet_link_tag 'project_component' + +#benchmark-viewer + %benchmarkviewer{ | + 'v-bind:benchmark': @srg_json, | + 'v-bind:type': "'srg'".html_safe | + } diff --git a/app/views/stigs/show.html.haml b/app/views/stigs/show.html.haml index f073042b4..08a3a0294 100644 --- a/app/views/stigs/show.html.haml +++ b/app/views/stigs/show.html.haml @@ -3,9 +3,6 @@ = stylesheet_link_tag 'project_component' #stig - %stig{ | - 'v-bind:queried-rule': (@rule_json || {}.to_json), | - 'v-bind:stig': @stig_json, | - 'v-bind:severities': RuleConstants::SEVERITIES.to_json, | - 'v-bind:severities_map': RuleConstants::SEVERITIES_MAP.to_json, | + %stig{ | + 'v-bind:stig': @stig_json | } diff --git a/config/routes.rb b/config/routes.rb index 1d83cbb93..a4d71e630 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,7 +15,7 @@ } resources :users, only: %i[index create update destroy] - resources :srgs, only: %i[index create destroy], controller: 'security_requirements_guides' + resources :srgs, only: %i[index show create destroy], controller: 'security_requirements_guides' resources :stigs, only: %i[index show create destroy] resources :memberships, only: %i[create update destroy] diff --git a/spec/javascript/components/shared/BenchmarkViewer.spec.js b/spec/javascript/components/shared/BenchmarkViewer.spec.js new file mode 100644 index 000000000..5d398d208 --- /dev/null +++ b/spec/javascript/components/shared/BenchmarkViewer.spec.js @@ -0,0 +1,153 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { shallowMount, createLocalVue } from '@vue/test-utils' +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' +import BenchmarkViewer from '@/components/shared/BenchmarkViewer.vue' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) +localVue.use(IconsPlugin) + +/** + * BenchmarkViewer Component Requirements + * + * REQUIREMENTS: + * + * 1. BREADCRUMB: + * - Shows benchmark type and title + * - Links back to list page + * + * 2. COMMAND BAR: + * - Uses BaseCommandBar + * - LEFT: Download button, Back to list + * - RIGHT: Empty for now + * + * 3. THREE-COLUMN LAYOUT: + * - LEFT: Item list (rules/requirements/controls) + * - MIDDLE: Item details + * - RIGHT: Item overview/metadata + * + * 4. USES useBenchmarkViewer COMPOSABLE: + * - Navigation, search, filtering handled by composable + * - Component focuses on presentation + * + * 5. TYPE-AGNOSTIC: + * - Works for STIG, SRG, CIS via type prop + * - Adapts labels and fields based on type + */ +describe('BenchmarkViewer', () => { + let wrapper + + const stigBenchmark = { + id: 1, + title: 'Test STIG', + version: 'V1R1', + stig_rules: [ + { id: 1, rule_id: 'SV-001', title: 'Rule One', severity: 'high' }, + { id: 2, rule_id: 'SV-002', title: 'Rule Two', severity: 'medium' } + ] + } + + const createWrapper = (props = {}) => { + return shallowMount(BenchmarkViewer, { + localVue, + propsData: { + benchmark: stigBenchmark, + type: 'stig', + ...props + }, + stubs: { + BBreadcrumb: true, + BaseCommandBar: true, + StigRuleList: true, + StigRuleDetails: true, + StigRuleOverview: true + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + describe('breadcrumb', () => { + it('renders breadcrumb', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'BBreadcrumb' }).exists()).toBe(true) + }) + + it('breadcrumb shows type and title for STIG', () => { + wrapper = createWrapper({ type: 'stig' }) + const crumbs = wrapper.vm.breadcrumbs + expect(crumbs.some(c => c.text === 'STIGs')).toBe(true) + expect(crumbs.some(c => c.text.includes('Test STIG'))).toBe(true) + }) + + it('breadcrumb shows type and title for SRG', () => { + const srgBenchmark = { ...stigBenchmark, title: 'Test SRG', requirements: [] } + wrapper = createWrapper({ benchmark: srgBenchmark, type: 'srg' }) + const crumbs = wrapper.vm.breadcrumbs + expect(crumbs.some(c => c.text === 'SRGs')).toBe(true) + }) + }) + + describe('command bar', () => { + it('renders BaseCommandBar', () => { + wrapper = createWrapper() + expect(wrapper.findComponent({ name: 'BaseCommandBar' }).exists()).toBe(true) + }) + + it('has Download button', () => { + wrapper = createWrapper() + // Should have download functionality + expect(wrapper.vm.openExportModal).toBeDefined() + }) + }) + + describe('three-column layout', () => { + it('renders StigRuleList component for list column', () => { + wrapper = createWrapper({ type: 'stig' }) + expect(wrapper.findComponent({ name: 'StigRuleList' }).exists()).toBe(true) + }) + + it('renders StigRuleDetails component for details column', () => { + wrapper = createWrapper({ type: 'stig' }) + expect(wrapper.findComponent({ name: 'StigRuleDetails' }).exists()).toBe(true) + }) + + it('renders StigRuleOverview component for overview column', () => { + wrapper = createWrapper({ type: 'stig' }) + expect(wrapper.findComponent({ name: 'StigRuleOverview' }).exists()).toBe(true) + }) + }) + + describe('uses composable', () => { + it('initializes useBenchmarkViewer composable', () => { + wrapper = createWrapper() + // Composable provides selectedItem + expect(wrapper.vm.selectedItem).toBeDefined() + }) + + it('provides filteredItems from composable', () => { + wrapper = createWrapper() + expect(wrapper.vm.filteredItems).toBeDefined() + expect(wrapper.vm.filteredItems.length).toBe(2) + }) + }) + + describe('type adaptation', () => { + it('adapts for STIG type', () => { + wrapper = createWrapper({ type: 'stig' }) + expect(wrapper.vm.benchmarkType).toBe('stig') + expect(wrapper.vm.itemTypeName).toBe('rule') + }) + + it('adapts for SRG type', () => { + const srgBenchmark = { ...stigBenchmark, requirements: [] } + wrapper = createWrapper({ benchmark: srgBenchmark, type: 'srg' }) + expect(wrapper.vm.benchmarkType).toBe('srg') + expect(wrapper.vm.itemTypeName).toBe('requirement') + }) + }) +}) From 3310079932e527dae3d971ccd2b2d75fc0ace569 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 14:20:13 -0500 Subject: [PATCH 088/428] WIP: Start RuleList refactoring with RULE_TERM integration Work-in-progress: Refactoring StigRuleList to generic RuleList with terminology constants. Component copied and partially updated. - Created benchmarks/ directory for shared benchmark components - RuleList.vue: Added type prop, imported RULE_TERM, updated some labels - RuleList.spec.js: Comprehensive tests (currently failing) STATUS: Not working yet - needs completion of RULE_TERM integration, computed properties, and method updates. Agent will complete in next session. Authored by: Aaron Lippold --- .../components/benchmarks/RuleList.vue | 156 ++++++++++++++ .../components/benchmarks/RuleList.spec.js | 193 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 app/javascript/components/benchmarks/RuleList.vue create mode 100644 spec/javascript/components/benchmarks/RuleList.spec.js diff --git a/app/javascript/components/benchmarks/RuleList.vue b/app/javascript/components/benchmarks/RuleList.vue new file mode 100644 index 000000000..a718a9d55 --- /dev/null +++ b/app/javascript/components/benchmarks/RuleList.vue @@ -0,0 +1,156 @@ + + + + + + diff --git a/spec/javascript/components/benchmarks/RuleList.spec.js b/spec/javascript/components/benchmarks/RuleList.spec.js new file mode 100644 index 000000000..7eede4f6b --- /dev/null +++ b/spec/javascript/components/benchmarks/RuleList.spec.js @@ -0,0 +1,193 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { shallowMount, createLocalVue } from '@vue/test-utils' +import { BootstrapVue } from 'bootstrap-vue' +import RuleList from '@/components/benchmarks/RuleList.vue' +import { RULE_TERM } from '@/constants/terminology' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) + +/** + * RuleList Component Requirements + * + * REQUIREMENTS: + * + * 1. GENERIC (works for STIG and SRG): + * - Accepts type prop ('stig' | 'srg') + * - Uses RULE_TERM constants for labels + * + * 2. SEARCH: + * - Search by rule ID or title + * - Uses RULE_TERM in placeholder + * + * 3. FILTER BY SEVERITY: + * - High, Medium, Low, All buttons + * - Shows count for each + * + * 4. RULE LIST: + * - Sorted by selected field (rule_id, title, severity) + * - Click rule to select + * - Highlight selected rule + * + * 5. TERMINOLOGY: + * - "Requirements" → RULE_TERM.plural + * - "Rule" → RULE_TERM.singular + * - "Search Rule" → `Search ${RULE_TERM.singular}` + */ +describe('RuleList', () => { + let wrapper + + const sampleRules = [ + { id: 1, rule_id: 'SV-001', title: 'Rule One', rule_severity: 'high' }, + { id: 2, rule_id: 'SV-002', title: 'Rule Two', rule_severity: 'medium' }, + { id: 3, rule_id: 'SV-003', title: 'Rule Three', rule_severity: 'low' } + ] + + const createWrapper = (props = {}) => { + return shallowMount(RuleList, { + localVue, + propsData: { + rules: sampleRules, + initialSelectedRule: sampleRules[0], + type: 'stig', + ...props + } + }) + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy() + } + }) + + // ========================================== + // TERMINOLOGY INTEGRATION + // ========================================== + describe('RULE_TERM integration', () => { + it('uses RULE_TERM.plural for list title', () => { + wrapper = createWrapper() + expect(wrapper.text()).toContain(RULE_TERM.plural) + }) + + it('uses RULE_TERM.singular in search placeholder', () => { + wrapper = createWrapper() + const placeholder = wrapper.find('input[type="text"]').attributes('placeholder') + expect(placeholder).toContain(RULE_TERM.singular) + }) + + it('does not have hardcoded "Requirements" string', () => { + wrapper = createWrapper() + // Should use RULE_TERM.plural instead + const html = wrapper.html() + expect(html).not.toMatch(/(? { + wrapper = createWrapper() + const html = wrapper.html() + // Should use RULE_TERM.singular, not hardcoded + expect(html).not.toMatch(/(? { + it('accepts stig type', () => { + wrapper = createWrapper({ type: 'stig' }) + expect(wrapper.props('type')).toBe('stig') + }) + + it('accepts srg type', () => { + wrapper = createWrapper({ type: 'srg' }) + expect(wrapper.props('type')).toBe('srg') + }) + + it('type prop is required', () => { + expect(wrapper.vm.$options.props.type.required).toBe(true) + }) + }) + + // ========================================== + // SEARCH + // ========================================== + describe('search functionality', () => { + it('filters by rule_id', async () => { + wrapper = createWrapper() + await wrapper.setData({ searchText: 'SV-001' }) + const filtered = wrapper.vm.filteredRules + expect(filtered.length).toBe(1) + expect(filtered[0].rule_id).toBe('SV-001') + }) + + it('filters by title', async () => { + wrapper = createWrapper() + await wrapper.setData({ searchText: 'Rule Two' }) + const filtered = wrapper.vm.filteredRules + expect(filtered.length).toBe(1) + expect(filtered[0].title).toBe('Rule Two') + }) + + it('search is case-insensitive', async () => { + wrapper = createWrapper() + await wrapper.setData({ searchText: 'rule one' }) + const filtered = wrapper.vm.filteredRules + expect(filtered.length).toBe(1) + }) + }) + + // ========================================== + // SEVERITY FILTER + // ========================================== + describe('severity filtering', () => { + it('filters by high severity', async () => { + wrapper = createWrapper() + await wrapper.setData({ severity: 'high' }) + const filtered = wrapper.vm.filteredRules + expect(filtered.length).toBe(1) + expect(filtered[0].rule_severity).toBe('high') + }) + + it('shows all when severity is empty', async () => { + wrapper = createWrapper() + await wrapper.setData({ severity: '' }) + const filtered = wrapper.vm.filteredRules + expect(filtered.length).toBe(3) + }) + + it('counts high severity rules', () => { + wrapper = createWrapper() + expect(wrapper.vm.high_count).toBe(1) + }) + + it('counts medium severity rules', () => { + wrapper = createWrapper() + expect(wrapper.vm.medium_count).toBe(1) + }) + + it('counts low severity rules', () => { + wrapper = createWrapper() + expect(wrapper.vm.low_count).toBe(1) + }) + }) + + // ========================================== + // RULE SELECTION + // ========================================== + describe('rule selection', () => { + it('emits rule-selected when rule clicked', async () => { + wrapper = createWrapper() + wrapper.vm.selectRule(sampleRules[1]) + expect(wrapper.emitted('rule-selected')).toBeTruthy() + expect(wrapper.emitted('rule-selected')[0]).toEqual([sampleRules[1]]) + }) + + it('highlights selected rule', () => { + wrapper = createWrapper({ initialSelectedRule: sampleRules[1] }) + // Selected rule should have active class or styling + expect(wrapper.vm.selectedRule).toEqual(sampleRules[1]) + }) + }) +}) From 833337bd86eb6fcf32ab61a659d16a33f0e18b4d Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Feb 2026 14:20:35 -0500 Subject: [PATCH 089/428] docs: Add BenchmarkViewer architecture design document Comprehensive design document for backporting v2.3.0 BenchmarkViewer pattern to v2.2.x. Documents adapter pattern, data normalization, and implementation plan. - Architecture analysis of v2.3.0 TypeScript implementation - Adapter pattern for normalizing STIG/SRG to unified structure - 7-phase implementation plan with TDD approach - Component specifications and test requirements - Integration with RULE_TERM terminology constants Reference for completing BenchmarkViewer implementation. Authored by: Aaron Lippold --- BENCHMARK-VIEWER-DESIGN.md | 2086 ++++++++++++++++++++++++++++++++++++ 1 file changed, 2086 insertions(+) create mode 100644 BENCHMARK-VIEWER-DESIGN.md diff --git a/BENCHMARK-VIEWER-DESIGN.md b/BENCHMARK-VIEWER-DESIGN.md new file mode 100644 index 000000000..21320e15b --- /dev/null +++ b/BENCHMARK-VIEWER-DESIGN.md @@ -0,0 +1,2086 @@ +# BenchmarkViewer v2.2.x Design Document + +**Version:** 2.2.x (Vue 2.7 + Bootstrap 4.6) +**Reference Implementation:** v2.3.0 (Vue 3 + TypeScript) +**Date:** 2025-02-05 + +## Table of Contents +- [Executive Summary](#executive-summary) +- [Architecture Overview](#architecture-overview) +- [v2.3.0 Analysis](#v230-analysis) +- [v2.2.x Design](#v22x-design) +- [Data Flow](#data-flow) +- [Component Specifications](#component-specifications) +- [Implementation Plan](#implementation-plan) + +--- + +## Executive Summary + +This document provides a complete design for backporting the v2.3.0 BenchmarkViewer pattern to v2.2.x. The v2.3.0 implementation uses TypeScript adapters to normalize STIG and SRG data into a unified interface, allowing shared components for viewing benchmarks. + +**Key Pattern:** Adapter → Unified Interface → Shared Components + +**v2.2.x Constraints:** +- Vue 2.7 (no ` +``` + +**STIG-specific content:** +```vue + +
  • + Vuln ID: {{ rule.vuln_id }} +
  • +``` + +### State Management (BenchmarkViewer.vue) + +State management is simple - no composable needed in v2.3.0: + +```javascript +// Selected rule state +const selectedRule = ref(null); + +// Sort rules and select first one on mount +const sortedRules = computed(() => { + if (!props.benchmark.rules) return []; + return [...props.benchmark.rules].sort((a, b) => + a.rule_id.localeCompare(b.rule_id) + ); +}); + +// Select initial rule +watch(() => sortedRules.value, (rules) => { + if (rules.length > 0 && !selectedRule.value) { + selectedRule.value = rules[0]; + } +}, { immediate: true }); + +// Handle rule selection from list +function onRuleSelected(rule) { + selectedRule.value = rule; +} +``` + +**No composable needed** because: +- State is just `selectedRule` (single ref) +- Sorting is a computed property +- Selection is a simple event handler + +The v2.2.x `useBenchmarkViewer` composable is **over-engineered** for this use case. We can simplify. + +--- + +## v2.2.x Design + +### Adapter Layer (app/javascript/adapters/benchmark.js) + +**NEW FILE** - Normalizes STIG/SRG data to unified format. + +```javascript +/** + * Benchmark Adapters + * + * Normalize STIG and SRG data into unified benchmark format. + * Adapts different field names to common interface. + */ + +/** + * Convert STIG to unified benchmark format + * @param {Object} stig - STIG object from API + * @returns {Object} Unified benchmark + */ +export function stigToBenchmark(stig) { + return { + id: stig.id, + benchmark_id: stig.stig_id, + title: stig.title, + name: stig.name, + version: stig.version, + date: stig.benchmark_date, + description: stig.description, + created_at: stig.created_at, + updated_at: stig.updated_at, + rules: stig.stig_rules?.map(stigRuleToBenchmarkRule) || [] + }; +} + +/** + * Convert SRG to unified benchmark format + * @param {Object} srg - SRG object from API + * @returns {Object} Unified benchmark + */ +export function srgToBenchmark(srg) { + return { + id: srg.id, + benchmark_id: srg.srg_id, + title: srg.title, + name: srg.name, + version: srg.version, + date: srg.release_date, + created_at: srg.created_at, + updated_at: srg.updated_at, + rules: srg.srg_rules?.map(srgRuleToBenchmarkRule) || [] + }; +} + +/** + * Convert STIG rule to unified benchmark rule format + * @param {Object} rule - STIG rule from API + * @returns {Object} Normalized rule + */ +export function stigRuleToBenchmarkRule(rule) { + return { + id: rule.id, + rule_id: rule.rule_id || '', + version: rule.version, + title: rule.title, + rule_severity: rule.rule_severity || 'medium', + rule_weight: rule.rule_weight, + ident: rule.ident, + ident_system: rule.ident_system, + legacy_ids: rule.legacy_ids, + fixtext: rule.fixtext, + fixtext_fixref: rule.fixtext_fixref, + fix_id: rule.fix_id, + nist_control_family: rule.nist_control_family, + // STIG-specific fields + vuln_id: rule.vuln_id, + srg_id: rule.srg_id, + stig_id: rule.stig_id, + vendor_comments: rule.vendor_comments, + // Nested attributes (pass through) + checks_attributes: rule.checks_attributes, + disa_rule_descriptions_attributes: rule.disa_rule_descriptions_attributes + }; +} + +/** + * Convert SRG rule to unified benchmark rule format + * @param {Object} rule - SRG rule from API + * @returns {Object} Normalized rule + */ +export function srgRuleToBenchmarkRule(rule) { + return { + id: rule.id, + rule_id: rule.rule_id, + version: rule.version, + title: rule.title, + rule_severity: rule.rule_severity, + rule_weight: rule.rule_weight, + ident: rule.ident, + ident_system: rule.ident_system, + legacy_ids: rule.legacy_ids, + fixtext: rule.fixtext, + fixtext_fixref: rule.fixtext_fixref, + fix_id: rule.fix_id, + nist_control_family: rule.nist_control_family, + // SRG-specific fields + security_requirements_guide_id: rule.security_requirements_guide_id, + // Nested attributes (pass through) + checks_attributes: rule.checks_attributes, + disa_rule_descriptions_attributes: rule.disa_rule_descriptions_attributes + }; +} +``` + +### Utilities (app/javascript/utils/ident-parser.js) + +**NEW FILE** - Port from v2.3.0 (TypeScript → JavaScript). + +```javascript +/** + * Ident Parser Utility + * + * Parses XCCDF ident strings into categorized arrays for display. + * + * XCCDF idents include multiple identifier types: + * - CCIs (CCI-000000): DISA Control Correlation Identifiers + * - CIS Controls v7 (7:X.Y): CIS Critical Security Controls v7 + * - CIS Controls v8 (8:X.Y): CIS Critical Security Controls v8 + * - MITRE ATT&CK Techniques (T0000): Attack techniques + * - MITRE ATT&CK Tactics (TA0000): Attack tactics + * - MITRE ATT&CK Mitigations (M0000): Mitigations + */ + +/** + * Parse a comma-separated ident string into categorized arrays + * + * @param {string|null|undefined} ident - Comma-separated string of identifiers + * @returns {Object} Categorized ident arrays + * + * @example + * const parsed = parseIdents('CCI-000366, 8:3.14, 7:14.9, T1565, TA0001, M1022') + * // Returns: + * // { + * // ccis: ['CCI-000366'], + * // cisV7: ['7:14.9'], + * // cisV8: ['8:3.14'], + * // mitreTechniques: ['T1565'], + * // mitreTactics: ['TA0001'], + * // mitreMitigations: ['M1022'], + * // other: [] + * // } + */ +export function parseIdents(ident) { + const result = { + ccis: [], + cisV7: [], + cisV8: [], + mitreTechniques: [], + mitreTactics: [], + mitreMitigations: [], + other: [] + }; + + if (!ident) return result; + + const idents = ident.split(/,\s*/); + + for (const item of idents) { + const trimmed = item.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith('CCI-')) { + result.ccis.push(trimmed); + } else if (trimmed.startsWith('7:')) { + result.cisV7.push(trimmed); + } else if (trimmed.startsWith('8:')) { + result.cisV8.push(trimmed); + } else if (/^T\d/.test(trimmed)) { + result.mitreTechniques.push(trimmed); + } else if (/^TA\d/.test(trimmed)) { + result.mitreTactics.push(trimmed); + } else if (/^M\d/.test(trimmed)) { + result.mitreMitigations.push(trimmed); + } else { + result.other.push(trimmed); + } + } + + return result; +} + +/** + * Check if parsed idents has any CIS Controls data + * @param {Object} parsed - Parsed idents object + * @returns {boolean} + */ +export function hasCisControls(parsed) { + return parsed.cisV7.length > 0 || parsed.cisV8.length > 0; +} + +/** + * Check if parsed idents has any MITRE ATT&CK data + * @param {Object} parsed - Parsed idents object + * @returns {boolean} + */ +export function hasMitreData(parsed) { + return parsed.mitreTechniques.length > 0 + || parsed.mitreTactics.length > 0 + || parsed.mitreMitigations.length > 0; +} + +/** + * Format CIS Control for display (strips version prefix) + * @param {string} control - CIS control string (e.g., '8:3.14') + * @returns {string} Formatted control (e.g., '3.14') + * @example formatCisControl('8:3.14') => '3.14' + */ +export function formatCisControl(control) { + return control.replace(/^\d:/, ''); +} +``` + +### Shared Components + +#### RuleList.vue (RENAME from StigRuleList.vue) + +**Location:** `app/javascript/components/shared/RuleList.vue` + +**Changes from StigRuleList:** +1. Add `type` prop +2. Use RULE_TERM constants +3. Computed properties for type-specific labels +4. Remove hardcoded "STIG ID" / "SRG ID" labels + +```vue + + + +``` + +#### RuleDetails.vue (RENAME from StigRuleDetails.vue) + +**Location:** `app/javascript/components/shared/RuleDetails.vue` + +**Changes from StigRuleDetails:** +1. Add `type` prop (for future expansion) +2. Use selectedRule prop name consistently +3. No hardcoded changes needed (already generic) + +```vue + + + +``` + +#### RuleOverview.vue (RENAME from StigRuleOverview.vue) + +**Location:** `app/javascript/components/shared/RuleOverview.vue` + +**Changes from StigRuleOverview:** +1. Add `type` prop +2. Use RULE_TERM constants +3. Conditional rendering for STIG-specific fields +4. Add CIS Controls and MITRE ATT&CK parsing + +```vue + + + +``` + +### BenchmarkViewer.vue (UPDATE existing) + +**Location:** `app/javascript/components/shared/BenchmarkViewer.vue` + +**Changes:** +1. Remove useBenchmarkViewer composable (over-engineered) +2. Add simple state management (selectedRule ref) +3. Use shared RuleList/RuleDetails/RuleOverview components +4. Pass `type` prop to all child components + +```vue + + + +``` + +### Page Wrappers + +#### Stig.vue (UPDATE existing) + +**Location:** `app/javascript/components/stigs/Stig.vue` + +**Changes:** +1. Import stigToBenchmark adapter +2. Apply adapter before passing to BenchmarkViewer + +```vue + + + +``` + +#### Srg.vue (NEW FILE) + +**Location:** `app/javascript/components/srgs/Srg.vue` + +**Pattern:** Identical to Stig.vue but for SRGs. + +```vue + + + +``` + +--- + +## Data Flow + +### STIG Viewing + +``` +1. User visits /stigs/:id + ↓ +2. Rails renders views/stigs/show.html.haml + ↓ +3. HAML passes @stig (with stig_rules) to Stig.vue + ↓ +4. Stig.vue applies stigToBenchmark adapter: + - stig_id → benchmark_id + - benchmark_date → date + - stig_rules → rules (mapped with stigRuleToBenchmarkRule) + ↓ +5. Passes adapted benchmark + type='stig' to BenchmarkViewer + ↓ +6. BenchmarkViewer: + - Sorts rules by rule_id + - Selects first rule + - Renders RuleList, RuleDetails, RuleOverview with type='stig' + ↓ +7. Components use type prop to customize: + - Labels: "SRG ID" vs "Rule ID" + - Conditional fields: vuln_id, srg_id (STIG-only) +``` + +### SRG Viewing + +``` +1. User visits /srgs/:id + ↓ +2. Rails renders views/srgs/show.html.haml + ↓ +3. HAML passes @srg (with srg_rules) to Srg.vue + ↓ +4. Srg.vue applies srgToBenchmark adapter: + - srg_id → benchmark_id + - release_date → date + - srg_rules → rules (mapped with srgRuleToBenchmarkRule) + ↓ +5. Passes adapted benchmark + type='srg' to BenchmarkViewer + ↓ +6. BenchmarkViewer: + - Sorts rules by rule_id + - Selects first rule + - Renders RuleList, RuleDetails, RuleOverview with type='srg' + ↓ +7. Components use type prop to customize: + - Labels: "Rule ID", "Version" + - Hides STIG-specific fields (vuln_id, srg_id) +``` + +--- + +## Component Specifications + +### Props Interface + +**BenchmarkViewer:** +- `benchmark` (Object, required) - Adapted benchmark data (unified format) +- `type` (String, required) - 'stig' | 'srg' + +**RuleList, RuleDetails, RuleOverview:** +- `type` (String, required) - 'stig' | 'srg' +- `selectedRule` (Object, required) - Current rule from adapted benchmark + +### Events + +**RuleList:** +- `@rule-selected` - Emits selected rule object + +### Terminology Integration + +All components use `RULE_TERM` constants: + +```javascript +import { RULE_TERM } from '../../constants/terminology'; + +// Usage in template: +
    {{ RULE_TERM.plural }}
    +
    Select a {{ RULE_TERM.singular.toLowerCase() }} to view
    +``` + +### Type-Specific Display + +Components use computed properties and conditional rendering: + +```vue + + + +``` + +--- + +## Implementation Plan + +### Phase 1: Create Adapter Layer (TDD) + +**Tests:** `app/javascript/__tests__/adapters/benchmark.spec.js` + +1. **Test stigToBenchmark adapter:** + ```javascript + describe('stigToBenchmark', () => { + it('normalizes stig_id to benchmark_id', () => { + const stig = { stig_id: 'test-stig', /* ... */ }; + const result = stigToBenchmark(stig); + expect(result.benchmark_id).toBe('test-stig'); + }); + + it('normalizes benchmark_date to date', () => { + const stig = { benchmark_date: '2024-01-01', /* ... */ }; + const result = stigToBenchmark(stig); + expect(result.date).toBe('2024-01-01'); + }); + + it('maps stig_rules to rules array', () => { + const stig = { + stig_rules: [{ rule_id: 'SRG-001', /* ... */ }], + /* ... */ + }; + const result = stigToBenchmark(stig); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule_id).toBe('SRG-001'); + }); + + it('handles missing stig_rules gracefully', () => { + const stig = { /* no stig_rules */ }; + const result = stigToBenchmark(stig); + expect(result.rules).toEqual([]); + }); + }); + ``` + +2. **Test srgToBenchmark adapter:** + ```javascript + describe('srgToBenchmark', () => { + it('normalizes srg_id to benchmark_id', () => { + const srg = { srg_id: 'test-srg', /* ... */ }; + const result = srgToBenchmark(srg); + expect(result.benchmark_id).toBe('test-srg'); + }); + + it('normalizes release_date to date', () => { + const srg = { release_date: '2024-01-01', /* ... */ }; + const result = srgToBenchmark(srg); + expect(result.date).toBe('2024-01-01'); + }); + + it('maps srg_rules to rules array', () => { + const srg = { + srg_rules: [{ rule_id: 'SRG-001', /* ... */ }], + /* ... */ + }; + const result = srgToBenchmark(srg); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule_id).toBe('SRG-001'); + }); + }); + ``` + +3. **Test rule adapters:** + ```javascript + describe('stigRuleToBenchmarkRule', () => { + it('preserves all common fields', () => { + const rule = { + id: 1, + rule_id: 'SRG-001', + version: 'V-001', + title: 'Test Rule', + rule_severity: 'high', + /* ... */ + }; + const result = stigRuleToBenchmarkRule(rule); + expect(result.id).toBe(1); + expect(result.rule_id).toBe('SRG-001'); + expect(result.version).toBe('V-001'); + }); + + it('preserves STIG-specific fields', () => { + const rule = { + vuln_id: 'V-001', + srg_id: 'SRG-001', + stig_id: 123, + /* ... */ + }; + const result = stigRuleToBenchmarkRule(rule); + expect(result.vuln_id).toBe('V-001'); + expect(result.srg_id).toBe('SRG-001'); + expect(result.stig_id).toBe(123); + }); + }); + + describe('srgRuleToBenchmarkRule', () => { + it('preserves SRG-specific fields', () => { + const rule = { + security_requirements_guide_id: 456, + /* ... */ + }; + const result = srgRuleToBenchmarkRule(rule); + expect(result.security_requirements_guide_id).toBe(456); + }); + }); + ``` + +4. **Implementation:** + - Create `app/javascript/adapters/benchmark.js` + - Implement stigToBenchmark, srgToBenchmark + - Implement stigRuleToBenchmarkRule, srgRuleToBenchmarkRule + - Run tests: `yarn test:unit adapters/benchmark.spec.js` + - All tests pass ✓ + +### Phase 2: Create Utility Layer (TDD) + +**Tests:** `app/javascript/__tests__/utils/ident-parser.spec.js` + +1. **Test parseIdents utility:** + ```javascript + describe('parseIdents', () => { + it('parses CCIs correctly', () => { + const result = parseIdents('CCI-000366, CCI-001234'); + expect(result.ccis).toEqual(['CCI-000366', 'CCI-001234']); + }); + + it('parses CIS Controls v8', () => { + const result = parseIdents('8:3.14, 8:5.1'); + expect(result.cisV8).toEqual(['8:3.14', '8:5.1']); + }); + + it('parses MITRE ATT&CK techniques', () => { + const result = parseIdents('T1565, T1003.001'); + expect(result.mitreTechniques).toEqual(['T1565', 'T1003.001']); + }); + + it('handles null/undefined gracefully', () => { + expect(parseIdents(null)).toEqual({ + ccis: [], cisV7: [], cisV8: [], + mitreTechniques: [], mitreTactics: [], mitreMitigations: [], + other: [] + }); + }); + + it('parses mixed identifiers', () => { + const result = parseIdents('CCI-000366, 8:3.14, T1565, TA0001, M1022'); + expect(result.ccis).toEqual(['CCI-000366']); + expect(result.cisV8).toEqual(['8:3.14']); + expect(result.mitreTechniques).toEqual(['T1565']); + expect(result.mitreTactics).toEqual(['TA0001']); + expect(result.mitreMitigations).toEqual(['M1022']); + }); + }); + + describe('formatCisControl', () => { + it('strips version prefix from CIS control', () => { + expect(formatCisControl('8:3.14')).toBe('3.14'); + expect(formatCisControl('7:14.9')).toBe('14.9'); + }); + }); + ``` + +2. **Implementation:** + - Create `app/javascript/utils/ident-parser.js` + - Port TypeScript implementation to JavaScript + - Run tests: `yarn test:unit utils/ident-parser.spec.js` + - All tests pass ✓ + +### Phase 3: Refactor Shared Components (TDD) + +**Strategy:** Rename and enhance existing STIG components. + +#### 3.1: RuleList Component + +**Tests:** `app/javascript/__tests__/components/shared/RuleList.spec.js` + +1. **Test type-specific behavior:** + ```javascript + import { mount } from '@vue/test-utils'; + import RuleList from '@/components/shared/RuleList.vue'; + + describe('RuleList', () => { + const mockRules = [ + { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Test', rule_severity: 'high' }, + { id: 2, rule_id: 'SRG-002', version: 'V-002', title: 'Test 2', rule_severity: 'low' } + ]; + + describe('STIG mode', () => { + it('displays STIG-specific placeholder', () => { + const wrapper = mount(RuleList, { + propsData: { + type: 'stig', + rules: mockRules, + initialSelectedRule: mockRules[0] + } + }); + expect(wrapper.find('input').attributes('placeholder')) + .toBe('Search by STIG ID or SRG ID'); + }); + + it('displays STIG-specific field options', () => { + const wrapper = mount(RuleList, { + propsData: { type: 'stig', rules: mockRules, initialSelectedRule: mockRules[0] } + }); + expect(wrapper.vm.fieldOptions[0].text).toBe('SRG ID'); + expect(wrapper.vm.fieldOptions[1].text).toBe('STIG ID'); + }); + }); + + describe('SRG mode', () => { + it('displays SRG-specific placeholder', () => { + const wrapper = mount(RuleList, { + propsData: { + type: 'srg', + rules: mockRules, + initialSelectedRule: mockRules[0] + } + }); + expect(wrapper.find('input').attributes('placeholder')) + .toBe('Search by Rule ID or Version'); + }); + + it('displays SRG-specific field options', () => { + const wrapper = mount(RuleList, { + propsData: { type: 'srg', rules: mockRules, initialSelectedRule: mockRules[0] } + }); + expect(wrapper.vm.fieldOptions[0].text).toBe('Rule ID'); + expect(wrapper.vm.fieldOptions[1].text).toBe('Version'); + }); + }); + + it('emits rule-selected event when rule clicked', () => { + const wrapper = mount(RuleList, { + propsData: { type: 'stig', rules: mockRules, initialSelectedRule: mockRules[0] } + }); + wrapper.findAll('tr').at(1).trigger('click'); + expect(wrapper.emitted('rule-selected')[0][0]).toEqual(mockRules[1]); + }); + }); + ``` + +2. **Implementation:** + - Rename `app/javascript/components/stigs/StigRuleList.vue` → `app/javascript/components/shared/RuleList.vue` + - Add `type` prop + - Add computed properties for type-specific labels + - Import RULE_TERM constants + - Run tests: `yarn test:unit components/shared/RuleList.spec.js` + - All tests pass ✓ + +#### 3.2: RuleOverview Component + +**Tests:** `app/javascript/__tests__/components/shared/RuleOverview.spec.js` + +1. **Test conditional rendering:** + ```javascript + describe('RuleOverview', () => { + const stigRule = { + id: 1, + rule_id: 'SRG-001', + version: 'V-001', + title: 'Test', + rule_severity: 'high', + vuln_id: 'V-123456', + srg_id: 'SRG-001', + ident: 'CCI-000366, 8:3.14, T1565' + }; + + const srgRule = { + id: 2, + rule_id: 'SRG-001', + version: 'V1R1', + title: 'Test', + rule_severity: 'medium', + ident: 'CCI-000366' + }; + + describe('STIG mode', () => { + it('displays Vuln ID', () => { + const wrapper = mount(RuleOverview, { + propsData: { type: 'stig', selectedRule: stigRule } + }); + expect(wrapper.text()).toContain('Vuln ID'); + expect(wrapper.text()).toContain('V-123456'); + }); + + it('displays SRG ID', () => { + const wrapper = mount(RuleOverview, { + propsData: { type: 'stig', selectedRule: stigRule } + }); + expect(wrapper.text()).toContain('SRG ID'); + expect(wrapper.text()).toContain('SRG-001'); + }); + + it('displays STIG ID label for version', () => { + const wrapper = mount(RuleOverview, { + propsData: { type: 'stig', selectedRule: stigRule } + }); + expect(wrapper.text()).toContain('STIG ID'); + }); + }); + + describe('SRG mode', () => { + it('does not display Vuln ID', () => { + const wrapper = mount(RuleOverview, { + propsData: { type: 'srg', selectedRule: srgRule } + }); + expect(wrapper.text()).not.toContain('Vuln ID'); + }); + + it('does not display SRG ID', () => { + const wrapper = mount(RuleOverview, { + propsData: { type: 'srg', selectedRule: srgRule } + }); + expect(wrapper.text()).not.toContain('SRG ID'); + }); + + it('displays Version label for version', () => { + const wrapper = mount(RuleOverview, { + propsData: { type: 'srg', selectedRule: srgRule } + }); + expect(wrapper.text()).toContain('Version'); + }); + }); + + describe('ident parsing', () => { + it('displays parsed CCI', () => { + const wrapper = mount(RuleOverview, { + propsData: { type: 'stig', selectedRule: stigRule } + }); + expect(wrapper.text()).toContain('CCI-000366'); + }); + + it('displays parsed CIS Controls', () => { + const wrapper = mount(RuleOverview, { + propsData: { type: 'stig', selectedRule: stigRule } + }); + expect(wrapper.text()).toContain('CIS Controls v8'); + expect(wrapper.text()).toContain('3.14'); + }); + + it('displays parsed MITRE techniques', () => { + const wrapper = mount(RuleOverview, { + propsData: { type: 'stig', selectedRule: stigRule } + }); + expect(wrapper.text()).toContain('ATT&CK Techniques'); + expect(wrapper.text()).toContain('T1565'); + }); + }); + }); + ``` + +2. **Implementation:** + - Rename `app/javascript/components/stigs/StigRuleOverview.vue` → `app/javascript/components/shared/RuleOverview.vue` + - Add `type` prop + - Add conditional rendering for STIG-specific fields + - Import parseIdents, formatCisControl utilities + - Add CIS Controls and MITRE ATT&CK sections + - Run tests: `yarn test:unit components/shared/RuleOverview.spec.js` + - All tests pass ✓ + +#### 3.3: RuleDetails Component + +**Tests:** `app/javascript/__tests__/components/shared/RuleDetails.spec.js` + +1. **Test basic rendering:** + ```javascript + describe('RuleDetails', () => { + const mockRule = { + id: 1, + title: 'Test Rule', + fixtext: 'Fix instructions here', + vendor_comments: 'Vendor note', + disa_rule_descriptions_attributes: [ + { vuln_discussion: 'Vulnerability details' } + ], + checks_attributes: [ + { content: 'Check content' } + ] + }; + + it('renders rule title', () => { + const wrapper = mount(RuleDetails, { + propsData: { type: 'stig', selectedRule: mockRule } + }); + expect(wrapper.text()).toContain('Test Rule'); + }); + + it('renders fix text', () => { + const wrapper = mount(RuleDetails, { + propsData: { type: 'stig', selectedRule: mockRule } + }); + expect(wrapper.find('textarea[id^="rule-fixtext"]').element.value) + .toBe('Fix instructions here'); + }); + + it('renders vendor comments when present', () => { + const wrapper = mount(RuleDetails, { + propsData: { type: 'stig', selectedRule: mockRule } + }); + expect(wrapper.text()).toContain('Vendor Comments'); + }); + }); + ``` + +2. **Implementation:** + - Rename `app/javascript/components/stigs/StigRuleDetails.vue` → `app/javascript/components/shared/RuleDetails.vue` + - Add `type` prop (for future expansion) + - Update ID prefixes from `stig-rule-*` to `rule-*` + - Run tests: `yarn test:unit components/shared/RuleDetails.spec.js` + - All tests pass ✓ + +### Phase 4: Update BenchmarkViewer (TDD) + +**Tests:** `app/javascript/__tests__/components/shared/BenchmarkViewer.spec.js` + +1. **Test state management:** + ```javascript + describe('BenchmarkViewer', () => { + const mockBenchmark = { + id: 1, + title: 'Test STIG', + version: 'V1R1', + rules: [ + { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Rule 1', rule_severity: 'high' }, + { id: 2, rule_id: 'SRG-002', version: 'V-002', title: 'Rule 2', rule_severity: 'low' } + ] + }; + + it('selects first rule on mount', () => { + const wrapper = mount(BenchmarkViewer, { + propsData: { type: 'stig', benchmark: mockBenchmark } + }); + expect(wrapper.vm.selectedRule).toEqual(mockBenchmark.rules[0]); + }); + + it('sorts rules by rule_id', () => { + const unsortedBenchmark = { + ...mockBenchmark, + rules: [ + { id: 2, rule_id: 'SRG-002', version: 'V-002', title: 'Rule 2' }, + { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Rule 1' } + ] + }; + const wrapper = mount(BenchmarkViewer, { + propsData: { type: 'stig', benchmark: unsortedBenchmark } + }); + expect(wrapper.vm.sortedRules[0].rule_id).toBe('SRG-001'); + expect(wrapper.vm.sortedRules[1].rule_id).toBe('SRG-002'); + }); + + it('updates selectedRule when rule-selected event emitted', () => { + const wrapper = mount(BenchmarkViewer, { + propsData: { type: 'stig', benchmark: mockBenchmark } + }); + wrapper.vm.selectRule(mockBenchmark.rules[1]); + expect(wrapper.vm.selectedRule).toEqual(mockBenchmark.rules[1]); + }); + + it('passes type prop to child components', () => { + const wrapper = mount(BenchmarkViewer, { + propsData: { type: 'stig', benchmark: mockBenchmark } + }); + expect(wrapper.findComponent(RuleList).props('type')).toBe('stig'); + expect(wrapper.findComponent(RuleDetails).props('type')).toBe('stig'); + expect(wrapper.findComponent(RuleOverview).props('type')).toBe('stig'); + }); + }); + ``` + +2. **Implementation:** + - Update `app/javascript/components/shared/BenchmarkViewer.vue` + - Remove useBenchmarkViewer composable + - Add simple state management (selectedRule ref, sortedRules computed) + - Update component imports (RuleList, RuleDetails, RuleOverview from shared/) + - Pass `type` prop to all child components + - Run tests: `yarn test:unit components/shared/BenchmarkViewer.spec.js` + - All tests pass ✓ + +### Phase 5: Update Page Wrappers (TDD) + +#### 5.1: Stig.vue + +**Tests:** `app/javascript/__tests__/components/stigs/Stig.spec.js` + +1. **Test adapter integration:** + ```javascript + import { stigToBenchmark } from '@/adapters/benchmark'; + + describe('Stig.vue', () => { + const mockStig = { + id: 1, + stig_id: 'TEST_STIG', + title: 'Test STIG', + version: 'V1R1', + benchmark_date: '2024-01-01', + stig_rules: [ + { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Rule 1' } + ] + }; + + it('applies stigToBenchmark adapter', () => { + const wrapper = mount(Stig, { + propsData: { stig: mockStig } + }); + const adapted = wrapper.vm.adaptedStig; + expect(adapted.benchmark_id).toBe('TEST_STIG'); + expect(adapted.date).toBe('2024-01-01'); + expect(adapted.rules).toHaveLength(1); + }); + + it('passes adapted data to BenchmarkViewer', () => { + const wrapper = mount(Stig, { + propsData: { stig: mockStig } + }); + const benchmarkViewer = wrapper.findComponent(BenchmarkViewer); + expect(benchmarkViewer.props('benchmark').benchmark_id).toBe('TEST_STIG'); + expect(benchmarkViewer.props('type')).toBe('stig'); + }); + }); + ``` + +2. **Implementation:** + - Update `app/javascript/components/stigs/Stig.vue` + - Import stigToBenchmark adapter + - Add computed property: `adaptedStig` + - Pass `:benchmark="adaptedStig"` to BenchmarkViewer + - Run tests: `yarn test:unit components/stigs/Stig.spec.js` + - All tests pass ✓ + +#### 5.2: Srg.vue + +**Tests:** `app/javascript/__tests__/components/srgs/Srg.spec.js` + +1. **Test adapter integration:** + ```javascript + import { srgToBenchmark } from '@/adapters/benchmark'; + + describe('Srg.vue', () => { + const mockSrg = { + id: 1, + srg_id: 'TEST_SRG', + title: 'Test SRG', + version: 'V1R1', + release_date: '2024-01-01', + srg_rules: [ + { id: 1, rule_id: 'SRG-001', version: 'V1R1', title: 'Rule 1' } + ] + }; + + it('applies srgToBenchmark adapter', () => { + const wrapper = mount(Srg, { + propsData: { srg: mockSrg } + }); + const adapted = wrapper.vm.adaptedSrg; + expect(adapted.benchmark_id).toBe('TEST_SRG'); + expect(adapted.date).toBe('2024-01-01'); + expect(adapted.rules).toHaveLength(1); + }); + + it('passes adapted data to BenchmarkViewer', () => { + const wrapper = mount(Srg, { + propsData: { srg: mockSrg } + }); + const benchmarkViewer = wrapper.findComponent(BenchmarkViewer); + expect(benchmarkViewer.props('benchmark').benchmark_id).toBe('TEST_SRG'); + expect(benchmarkViewer.props('type')).toBe('srg'); + }); + }); + ``` + +2. **Implementation:** + - Create `app/javascript/components/srgs/Srg.vue` + - Import srgToBenchmark adapter + - Add computed property: `adaptedSrg` + - Render BenchmarkViewer with `:benchmark="adaptedSrg"` and `type="srg"` + - Run tests: `yarn test:unit components/srgs/Srg.spec.js` + - All tests pass ✓ + +### Phase 6: Integration Testing + +**Manual Testing Checklist:** + +1. **STIG Viewing (`/stigs/:id`):** + - [ ] Page loads without errors + - [ ] Breadcrumb shows "STIGs > {Title} {Version}" + - [ ] Left panel shows rule list with "SRG ID" / "STIG ID" toggle + - [ ] Middle panel shows rule details (vuln discussion, check, fix) + - [ ] Right panel shows rule overview with: + - [ ] Vuln ID (STIG-specific) + - [ ] Rule ID + - [ ] STIG ID + - [ ] SRG ID (STIG-specific) + - [ ] Severity badge + - [ ] CCI identifiers + - [ ] CIS Controls (if present) + - [ ] MITRE ATT&CK (if present) + - [ ] Clicking rule in list updates details/overview + - [ ] Search filters rules + - [ ] Severity filters work (High, Medium, Low, All) + - [ ] Download button opens export modal + +2. **SRG Viewing (`/srgs/:id`):** + - [ ] Page loads without errors + - [ ] Breadcrumb shows "SRGs > {Title} {Version}" + - [ ] Left panel shows rule list with "Rule ID" / "Version" toggle + - [ ] Middle panel shows rule details + - [ ] Right panel shows rule overview with: + - [ ] Rule ID + - [ ] Version (not labeled "STIG ID") + - [ ] Severity badge + - [ ] CCI identifiers + - [ ] NO Vuln ID field + - [ ] NO SRG ID field + - [ ] All interactions work same as STIG + +3. **Terminology:** + - [ ] All uses of "Rule" come from RULE_TERM constants + - [ ] No hardcoded "Rules" strings in components + +4. **Accessibility:** + - [ ] Tooltips work on info icons + - [ ] Links to CIS Controls and MITRE ATT&CK open in new tab + - [ ] Keyboard navigation works + - [ ] Screen reader friendly (ARIA labels correct) + +### Phase 7: Cleanup and Documentation + +1. **Delete old files:** + - Remove `app/javascript/components/stigs/StigRuleList.vue` (moved to shared/RuleList.vue) + - Remove `app/javascript/components/stigs/StigRuleDetails.vue` (moved to shared/RuleDetails.vue) + - Remove `app/javascript/components/stigs/StigRuleOverview.vue` (moved to shared/RuleOverview.vue) + +2. **Update imports:** + - Search for any remaining imports of old component paths + - Update to new shared/ paths + +3. **Documentation:** + - Update CLAUDE.md with BenchmarkViewer architecture + - Add JSDoc comments to adapter functions + - Update component README if exists + +4. **Final test run:** + ```bash + # Unit tests + yarn test:unit + + # Lint + yarn lint + + # Full suite + bundle exec rspec + ``` + +--- + +## Test-Driven Development Flow + +For each phase, follow this exact pattern: + +1. **RED Phase** - Write failing tests first + - Write test file before implementation + - Run tests: `yarn test:unit ` + - Tests FAIL (expected) ❌ + +2. **GREEN Phase** - Implement minimum code to pass + - Write implementation + - Run tests: `yarn test:unit ` + - Tests PASS ✓ + +3. **REFACTOR Phase** - Clean up code + - Improve readability + - Extract duplications + - Run tests: Still PASS ✓ + +4. **COMMIT** - Save progress + - Commit test file + implementation together + - Message: `test: Add [component] tests` + `feat: Implement [component]` + +**DO NOT:** +- Write implementation before tests +- Skip test files +- Modify tests just to make them pass +- Move to next phase with failing tests + +--- + +## Summary + +This design provides a complete, test-driven path to backport v2.3.0's BenchmarkViewer pattern to v2.2.x: + +**Key Differences from v2.3.0:** +- JavaScript adapters instead of TypeScript interfaces +- Vue 2.7 patterns (no ` + + diff --git a/app/javascript/components/security_requirements_guides/Srg.vue b/app/javascript/components/security_requirements_guides/Srg.vue new file mode 100644 index 000000000..54b772004 --- /dev/null +++ b/app/javascript/components/security_requirements_guides/Srg.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/javascript/components/shared/BenchmarkViewer.vue b/app/javascript/components/shared/BenchmarkViewer.vue index 0ff796f60..1e9380625 100644 --- a/app/javascript/components/shared/BenchmarkViewer.vue +++ b/app/javascript/components/shared/BenchmarkViewer.vue @@ -5,19 +5,10 @@ @@ -30,21 +21,22 @@ - - + - + @@ -63,9 +55,9 @@ import axios from "axios"; import BaseCommandBar from "./BaseCommandBar.vue"; import ExportModal from "./ExportModal.vue"; -import StigRuleList from "../stigs/StigRuleList.vue"; -import StigRuleDetails from "../stigs/StigRuleDetails.vue"; -import StigRuleOverview from "../stigs/StigRuleOverview.vue"; +import RuleList from "../benchmarks/RuleList.vue"; +import RuleDetails from "../benchmarks/RuleDetails.vue"; +import RuleOverview from "../benchmarks/RuleOverview.vue"; import AlertMixinVue from "../../mixins/AlertMixin.vue"; import { useBenchmarkViewer } from "../../composables"; @@ -74,9 +66,9 @@ export default { components: { BaseCommandBar, ExportModal, - StigRuleList, - StigRuleDetails, - StigRuleOverview, + RuleList, + RuleDetails, + RuleOverview, }, mixins: [AlertMixinVue], props: { @@ -87,7 +79,7 @@ export default { type: { type: String, required: true, - validator: (value) => ['stig', 'srg', 'cis'].includes(value), + validator: (value) => ["stig", "srg", "cis"].includes(value), }, }, setup(props) { @@ -125,25 +117,25 @@ export default { computed: { breadcrumbs() { return [ - { text: this.typeLabel + 's', href: this.listPath }, - { text: `${this.benchmark.title} ${this.benchmark.version || ''}`, active: true }, + { text: this.typeLabel + "s", href: this.listPath }, + { text: `${this.benchmark.title} ${this.benchmark.version || ""}`, active: true }, ]; }, typeLabel() { const labels = { - stig: 'STIG', - srg: 'SRG', - cis: 'CIS Benchmark', + stig: "STIG", + srg: "SRG", + cis: "CIS Benchmark", }; - return labels[this.type] || 'Benchmark'; + return labels[this.type] || "Benchmark"; }, listPath() { const paths = { - stig: '/stigs', - srg: '/srgs', - cis: '/stigs', // CIS shown in STIGs list + stig: "/stigs", + srg: "/srgs", + cis: "/stigs", // CIS shown in STIGs list }; - return paths[this.type] || '/'; + return paths[this.type] || "/"; }, }, methods: { @@ -151,7 +143,7 @@ export default { this.showExportModal = true; }, handleExport({ type, componentIds }) { - const benchmarkType = this.type === 'srg' ? 'srgs' : 'stigs'; + const benchmarkType = this.type === "srg" ? "srgs" : "stigs"; axios .get(`/${benchmarkType}/${this.benchmark.id}/export/${type}`) .then(() => { diff --git a/app/javascript/components/shared/ExportModal.vue b/app/javascript/components/shared/ExportModal.vue index 8e7f03048..7f049c51a 100644 --- a/app/javascript/components/shared/ExportModal.vue +++ b/app/javascript/components/shared/ExportModal.vue @@ -1,11 +1,5 @@ - - diff --git a/app/javascript/components/rules/forms/BasicRuleForm.vue b/app/javascript/components/rules/forms/BasicRuleForm.vue deleted file mode 100644 index 9710f32ca..000000000 --- a/app/javascript/components/rules/forms/BasicRuleForm.vue +++ /dev/null @@ -1,164 +0,0 @@ - - - - - diff --git a/spec/javascript/components/rules/forms/AdvancedRuleForm.spec.js b/spec/javascript/components/rules/forms/AdvancedRuleForm.spec.js deleted file mode 100644 index 01c52db28..000000000 --- a/spec/javascript/components/rules/forms/AdvancedRuleForm.spec.js +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { shallowMount, createLocalVue } from '@vue/test-utils' -import AdvancedRuleForm from '@/components/rules/forms/AdvancedRuleForm.vue' -import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' - -const localVue = createLocalVue() -localVue.use(BootstrapVue) -localVue.use(IconsPlugin) - -// REQUIREMENTS: -// 1. disaDescriptionFormFields controls which DISA description fields are -// visible to the author based on the rule's status. -// 2. severity_override_guidance must be shown for statuses where authors -// may need to document severity override justification: -// - "Applicable - Configurable" (full editing) -// - "Applicable - Does Not Meet" (gap documentation) -// - Rules with satisfied_by entries (treated like Configurable) -// 3. severity_override_guidance must NOT be shown for: -// - "Not Yet Determined" (no severity assessment yet) -// - "Applicable - Inherently Meets" (no override needed) -// - "Not Applicable" (no severity assessment needed) -// 4. ruleFormFields controls which top-level rule fields (status, title, -// severity, etc.) are visible per status. - -describe('AdvancedRuleForm', () => { - const defaultStatuses = [ - 'Not Yet Determined', - 'Applicable - Configurable', - 'Applicable - Inherently Meets', - 'Applicable - Does Not Meet', - 'Not Applicable' - ] - - const defaultSeverities = ['low', 'medium', 'high'] - - const createWrapper = (ruleOverrides = {}) => { - const defaultRule = { - status: 'Not Yet Determined', - satisfied_by: [], - locked: false, - review_requestor_id: null, - disa_rule_descriptions_attributes: [{ - _destroy: false, - vuln_discussion: '', - severity_override_guidance: '', - mitigation_control: '' - }], - checks_attributes: [{ content: '', _destroy: false }], - rule_descriptions_attributes: [], - nist_control_family: 'AC', - srg_rule_attributes: { title: 'Test SRG Rule' }, - ident: 'CCI-000001', - srg_info: { title: 'Test SRG' }, - ...ruleOverrides - } - - return shallowMount(AdvancedRuleForm, { - localVue, - propsData: { - rule: defaultRule, - statuses: defaultStatuses, - severities: defaultSeverities - } - }) - } - - describe('disaDescriptionFormFields', () => { - describe('"Applicable - Configurable" status', () => { - it('includes all DISA description fields', () => { - const wrapper = createWrapper({ status: 'Applicable - Configurable' }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).toEqual([ - 'documentable', - 'vuln_discussion', - 'false_positives', - 'false_negatives', - 'mitigations_available', - 'mitigations', - 'poam_available', - 'poam', - 'severity_override_guidance', - 'potential_impacts', - 'third_party_tools', - 'mitigation_control', - 'responsibility', - 'ia_controls' - ]) - expect(fields.disabled).toEqual([]) - }) - }) - - describe('"Applicable - Does Not Meet" status', () => { - it('shows exactly mitigation_control and severity_override_guidance', () => { - const wrapper = createWrapper({ status: 'Applicable - Does Not Meet' }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).toEqual(['mitigation_control', 'severity_override_guidance']) - expect(fields.disabled).toEqual([]) - }) - }) - - describe('"Not Yet Determined" status', () => { - it('shows only vuln_discussion', () => { - const wrapper = createWrapper({ status: 'Not Yet Determined', satisfied_by: [] }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).toEqual(['vuln_discussion']) - }) - - it('does not include severity_override_guidance', () => { - const wrapper = createWrapper({ status: 'Not Yet Determined', satisfied_by: [] }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).not.toContain('severity_override_guidance') - }) - }) - - describe('"Applicable - Inherently Meets" status', () => { - it('shows no DISA description fields', () => { - const wrapper = createWrapper({ status: 'Applicable - Inherently Meets' }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).toEqual([]) - }) - }) - - describe('"Not Applicable" status', () => { - it('shows no DISA description fields', () => { - const wrapper = createWrapper({ status: 'Not Applicable' }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).toEqual([]) - }) - }) - - describe('rules with satisfied_by entries', () => { - it('shows all DISA description fields regardless of status', () => { - const wrapper = createWrapper({ - status: 'Not Yet Determined', - satisfied_by: [{ id: 1 }] - }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).toContain('severity_override_guidance') - expect(fields.displayed).toContain('mitigation_control') - expect(fields.displayed).toContain('vuln_discussion') - }) - }) - }) - - describe('ruleFormFields', () => { - describe('"Applicable - Does Not Meet" status', () => { - it('shows status, status_justification, and vendor_comments', () => { - const wrapper = createWrapper({ status: 'Applicable - Does Not Meet' }) - const fields = wrapper.vm.ruleFormFields - - expect(fields.displayed).toEqual(['status', 'status_justification', 'vendor_comments']) - expect(fields.disabled).toEqual([]) - }) - }) - - describe('"Not Yet Determined" status', () => { - it('shows status and title (title disabled)', () => { - const wrapper = createWrapper({ status: 'Not Yet Determined' }) - const fields = wrapper.vm.ruleFormFields - - expect(fields.displayed).toEqual(['status', 'title']) - expect(fields.disabled).toEqual(['title']) - }) - }) - }) -}) diff --git a/spec/javascript/components/rules/forms/BasicRuleForm.spec.js b/spec/javascript/components/rules/forms/BasicRuleForm.spec.js deleted file mode 100644 index b43a01466..000000000 --- a/spec/javascript/components/rules/forms/BasicRuleForm.spec.js +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { shallowMount, createLocalVue } from '@vue/test-utils' -import BasicRuleForm from '@/components/rules/forms/BasicRuleForm.vue' -import BootstrapVue from 'bootstrap-vue' - -const localVue = createLocalVue() -localVue.use(BootstrapVue) - -describe('BasicRuleForm', () => { - const defaultStatuses = [ - 'Not Yet Determined', - 'Applicable - Configurable', - 'Applicable - Inherently Meets', - 'Applicable - Does Not Meet', - 'Not Applicable' - ] - - const defaultSeveritiesMap = { - low: 'CAT III', - medium: 'CAT II', - high: 'CAT I' - } - - const createWrapper = (ruleOverrides = {}) => { - const defaultRule = { - status: 'Not Yet Determined', - satisfied_by: [], - locked: false, - review_requestor_id: null, - disa_rule_descriptions_attributes: [{ vuln_discussion: '' }], - checks_attributes: [{ content: '' }], - // Props passed to RuleSecurityRequirementsGuideInformation - nist_control_family: 'AC', - srg_rule_attributes: { title: 'Test SRG Rule' }, - ident: 'CCI-000001', - srg_info: { title: 'Test SRG' }, - ...ruleOverrides - } - - return shallowMount(BasicRuleForm, { - localVue, - propsData: { - rule: defaultRule, - statuses: defaultStatuses, - severities_map: defaultSeveritiesMap - } - }) - } - - describe('disaDescriptionFormFields', () => { - it('shows vuln_discussion when status is Applicable - Configurable', () => { - const wrapper = createWrapper({ status: 'Applicable - Configurable' }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).toContain('vuln_discussion') - }) - - it('shows vuln_discussion when satisfied_by has entries (regardless of status)', () => { - const wrapper = createWrapper({ - status: 'Not Yet Determined', - satisfied_by: [{ id: 1 }] - }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).toContain('vuln_discussion') - }) - - it('does not show vuln_discussion when no satisfied_by and wrong status', () => { - const wrapper = createWrapper({ - status: 'Applicable - Inherently Meets', - satisfied_by: [] - }) - const fields = wrapper.vm.disaDescriptionFormFields - - expect(fields.displayed).not.toContain('vuln_discussion') - }) - }) - - describe('checkFormFields', () => { - it('shows content when status is Applicable - Configurable', () => { - const wrapper = createWrapper({ status: 'Applicable - Configurable' }) - const fields = wrapper.vm.checkFormFields - - expect(fields.displayed).toContain('content') - }) - - it('shows content when satisfied_by has entries (regardless of status)', () => { - const wrapper = createWrapper({ - status: 'Not Yet Determined', - satisfied_by: [{ id: 1 }] - }) - const fields = wrapper.vm.checkFormFields - - expect(fields.displayed).toContain('content') - }) - - it('does not show content when no satisfied_by and wrong status', () => { - const wrapper = createWrapper({ - status: 'Applicable - Inherently Meets', - satisfied_by: [] - }) - const fields = wrapper.vm.checkFormFields - - expect(fields.displayed).not.toContain('content') - }) - }) -}) From 57bdad89c9a58c5505c9dbeb7ac15e48528d0c57 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 7 Feb 2026 23:53:19 -0500 Subject: [PATCH 116/428] docs: Add rule form business rules documentation Documents field visibility, editability, and dynamic behavior rules for all 5 statuses. Covers severity override guidance, satisfied_by, collapsible sections, IA Control/CCI, and form disabled states. Authored by: Aaron Lippold --- docs/development/rule-form-business-rules.md | 188 +++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/development/rule-form-business-rules.md diff --git a/docs/development/rule-form-business-rules.md b/docs/development/rule-form-business-rules.md new file mode 100644 index 000000000..bc8610892 --- /dev/null +++ b/docs/development/rule-form-business-rules.md @@ -0,0 +1,188 @@ +# Rule Form Business Rules + +This document defines the field visibility, editability, and dynamic behavior rules for the Rule Editor form. These rules are implemented in `app/javascript/composables/ruleFieldConfig.js` and enforced by the `useRuleFormFields` composable. + +## Status-Based Field Visibility + +Each rule has a **status** that determines which fields are visible and editable. There are five statuses: + +### Applicable - Configurable + +The product requires configuration to achieve compliance. This is the primary authoring workflow. + +**Basic Mode:** + +| Field | Visible | Editable | +|-------|---------|----------| +| Status | Yes | Yes | +| Severity | Yes | Yes | +| Title | Yes | Yes | +| Fix | Yes | Yes | +| Vulnerability Discussion | Yes | Yes | +| Check | Yes | Yes | +| Vendor Comments | Yes | Yes | +| IA Control | Yes | Read-only | +| CCI | Yes | Read-only | + +**Advanced Mode adds:** + +| Field | Section | +|-------|---------| +| Status Justification | Rule | +| Version | Rule | +| Rule Weight | Rule | +| Artifact Description | Rule | +| Fix ID | Rule | +| Fix Text Reference | Rule | +| Identity | Rule | +| Identity System | Rule | +| Documentable | DISA (collapsible) | +| False Positives | DISA (collapsible) | +| False Negatives | DISA (collapsible) | +| Mitigations Available | DISA (collapsible) | +| Mitigations | DISA (collapsible) | +| POA&M Available | DISA (collapsible) | +| POA&M | DISA (collapsible) | +| Potential Impacts | DISA (collapsible) | +| Third Party Tools | DISA (collapsible) | +| Mitigation Control | DISA (collapsible) | +| Responsibility | DISA (collapsible) | +| IA Controls (DISA) | DISA (collapsible) | + +Advanced mode renders DISA and Checks in **collapsible sections** with headings. + +### Not Yet Determined + +The rule has not been evaluated yet. Fields show SRG boilerplate content as read-only context to help the author determine applicability. + +| Field | Visible | Editable | +|-------|---------|----------| +| Status | Yes | Yes | +| Severity | Yes | Disabled | +| Title | Yes | Disabled | +| Fix | Yes | Disabled | +| Vulnerability Discussion | Yes | Disabled | +| Check | Yes | Disabled | +| IA Control | Yes | Read-only | +| CCI | Yes | Read-only | + +Advanced mode does **not** add additional fields. Fields remain inline (no collapsible sections). + +### Applicable - Inherently Meets + +The product is compliant in its initial state and cannot be reconfigured to a noncompliant state. + +| Field | Visible | Editable | +|-------|---------|----------| +| Status | Yes | Yes | +| Severity | Yes | Yes | +| Status Justification | Yes | Yes | +| Artifact Description | Yes | Yes | +| Vendor Comments | Yes | Yes | +| IA Control | Yes | Read-only | +| CCI | Yes | Read-only | + +No DISA or Check fields. Advanced mode does not add fields. + +### Applicable - Does Not Meet + +There are no technical means to achieve compliance. + +**Basic Mode:** + +| Field | Visible | Editable | +|-------|---------|----------| +| Status | Yes | Yes | +| Severity | Yes | Yes | +| Status Justification | Yes | Yes | +| Vendor Comments | Yes | Yes | +| Mitigations Available | Yes | Yes | +| Mitigations | Yes | Yes | +| Mitigation Control | Yes | Yes | +| POA&M Available | Yes | Yes | +| POA&M | Yes | Yes | +| IA Control | Yes | Read-only | +| CCI | Yes | Read-only | + +**Advanced Mode adds** (in collapsible DISA section): + +Documentable, False Positives, False Negatives, Potential Impacts, Third Party Tools, Responsibility, IA Controls (DISA). + +### Not Applicable + +The requirement addresses a capability or use case the product does not support. + +| Field | Visible | Editable | +|-------|---------|----------| +| Status | Yes | Yes | +| Severity | Yes | Disabled | +| Status Justification | Yes | Yes | +| Artifact Description | Yes | Yes | +| Vendor Comments | Yes | Yes | +| IA Control | Yes | Read-only | +| CCI | Yes | Read-only | + +No DISA or Check fields. Advanced mode does not add fields. + +## Dynamic Behaviors + +### Severity Override Guidance + +When the author changes the severity from the SRG default value, a **Severity Override Guidance** field appears between the Severity and Title fields. This field is required to explain why the severity was changed. + +- **Appears when**: `rule.rule_severity !== rule.srg_rule_attributes.rule_severity` +- **Applicable statuses**: Configurable, Inherently Meets, Does Not Meet +- **Not shown for**: Not Applicable, Not Yet Determined (severity is disabled on these) +- **Data binding**: `rule.disa_rule_descriptions_attributes[0].severity_override_guidance` + +### Satisfied By + +When a rule is satisfied by another rule (`rule.satisfied_by.length > 0`): + +- The effective status is forced to **Configurable** regardless of the actual status +- The Configurable field set is used +- **Only `title` and `fixtext` are disabled** (content comes from the satisfying rule) +- The rest of the form remains editable (severity, vendor_comments, etc.) +- The entire form is **NOT** disabled + +### Collapsible Sections + +In advanced mode, DISA and Checks fields are rendered in collapsible sections **only when the status has advanced field additions**: + +- **Configurable**: Collapsible sections (has 12+ advanced DISA fields) +- **Does Not Meet**: Collapsible sections (has 7 advanced DISA fields) +- **NYD, Inherently Meets, Not Applicable**: Fields stay inline (no advanced additions) + +### IA Control and CCI + +These reference fields are **always visible** for all statuses and both modes. They are read-only and display the NIST control family (e.g., "AC-2") and Common Control Indicator (e.g., "CCI-000015") mapped to the requirement. + +### Mitigations / POA&M Toggle Pattern + +The `Mitigations Available` and `POA&M Available` fields are checkboxes that act as toggles. Their associated text fields (`Mitigations`, `POA&M`) only render when the parent checkbox is checked. + +## Form-Level Disabled States + +The entire form is disabled when any of these conditions are true: + +- `rule.locked === true` (rule has been locked by an admin) +- `rule.review_requestor_id !== null` (rule is under review) +- `readOnly === true` (viewer-only access) + +## Advanced Fields Toggle + +The Advanced Fields toggle is a component-level setting (not per-rule). When enabled: + +1. A confirmation dialog warns that most users do not need advanced fields +2. The setting is persisted to the server via PATCH +3. The toggle state is tracked locally (not via prop mutation) for Vue 2 slot reactivity + +## Configuration Source + +All field visibility rules are defined in a single configuration object in `app/javascript/composables/ruleFieldConfig.js`. The `useRuleFormFields` composable reads this config and applies dynamic logic (severity override, satisfied_by). + +``` +STATUS_FIELD_CONFIG[status].rule → { displayed, disabled, advancedDisplayed, advancedDisabled } +STATUS_FIELD_CONFIG[status].disa → { displayed, disabled, advancedDisplayed, advancedDisabled } +STATUS_FIELD_CONFIG[status].check → { displayed, disabled } +``` From a39f313f8f6faaab06101e3123966a134f13d743 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 8 Feb 2026 09:43:26 -0500 Subject: [PATCH 117/428] refactor: Remove dead severities prop from RuleForm chain RuleForm.vue uses SEVERITY_OPTIONS from terminology.js, making the severities prop unused. Remove it from RuleForm, UnifiedRuleForm, and the bindings in RuleEditor and RuleRevertModal. Authored by: Aaron Lippold --- app/javascript/components/rules/RuleEditor.vue | 1 - app/javascript/components/rules/RuleRevertModal.vue | 2 -- app/javascript/components/rules/forms/RuleForm.vue | 4 ---- app/javascript/components/rules/forms/UnifiedRuleForm.vue | 5 ----- spec/javascript/components/rules/forms/RuleForm.spec.js | 1 - 5 files changed, 13 deletions(-) diff --git a/app/javascript/components/rules/RuleEditor.vue b/app/javascript/components/rules/RuleEditor.vue index 29c68c75c..8f91caea6 100644 --- a/app/javascript/components/rules/RuleEditor.vue +++ b/app/javascript/components/rules/RuleEditor.vue @@ -65,7 +65,6 @@ @@ -150,7 +149,6 @@ v-else-if="history.auditable_type == 'Rule'" :rule="afterState" :statuses="statuses" - :severities="severities" :disabled="true" :valid-feedback="formFeedback" /> diff --git a/app/javascript/components/rules/forms/RuleForm.vue b/app/javascript/components/rules/forms/RuleForm.vue index e136e636e..e0dfb442b 100644 --- a/app/javascript/components/rules/forms/RuleForm.vue +++ b/app/javascript/components/rules/forms/RuleForm.vue @@ -552,10 +552,6 @@ export default { type: Array, required: true, }, - severities: { - type: [Array, Object], - required: true, - }, disabled: { type: Boolean, required: true, diff --git a/app/javascript/components/rules/forms/UnifiedRuleForm.vue b/app/javascript/components/rules/forms/UnifiedRuleForm.vue index 025e4ba1b..bd1843afb 100644 --- a/app/javascript/components/rules/forms/UnifiedRuleForm.vue +++ b/app/javascript/components/rules/forms/UnifiedRuleForm.vue @@ -4,7 +4,6 @@ [], - }, readOnly: { type: Boolean, default: false, diff --git a/spec/javascript/components/rules/forms/RuleForm.spec.js b/spec/javascript/components/rules/forms/RuleForm.spec.js index acda77113..cc65faf3b 100644 --- a/spec/javascript/components/rules/forms/RuleForm.spec.js +++ b/spec/javascript/components/rules/forms/RuleForm.spec.js @@ -87,7 +87,6 @@ describe('RuleForm', () => { propsData: { rule: makeRule(), statuses: defaultStatuses, - severities: [], disabled: false, fields: { displayed: ['status', 'rule_severity', 'title', 'fixtext', 'vendor_comments'], From cf667b4182a9dbc75fe379b76692fb4a63a7bead Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 8 Feb 2026 09:43:36 -0500 Subject: [PATCH 118/428] feat: Redesign RuleActionsToolbar with two-row layout Split toolbar into Info row (Related, Satisfies, History, Reviews, Comment) and Actions row (Review, Save, Clone | Delete, Lock). Add row labels, divider, and visual separation for destructive actions. Authored by: Aaron Lippold --- .../components/rules/RuleActionsToolbar.vue | 185 +++++++++--------- 1 file changed, 97 insertions(+), 88 deletions(-) diff --git a/app/javascript/components/rules/RuleActionsToolbar.vue b/app/javascript/components/rules/RuleActionsToolbar.vue index 987c95116..d823e419d 100644 --- a/app/javascript/components/rules/RuleActionsToolbar.vue +++ b/app/javascript/components/rules/RuleActionsToolbar.vue @@ -1,79 +1,83 @@ @@ -147,29 +151,34 @@ export default { diff --git a/app/javascript/components/benchmarks/RuleList.vue b/app/javascript/components/benchmarks/RuleList.vue index bd0aad330..a82ad77c7 100644 --- a/app/javascript/components/benchmarks/RuleList.vue +++ b/app/javascript/components/benchmarks/RuleList.vue @@ -10,7 +10,7 @@ class="form-control form-control-sm mb-2" :placeholder="searchPlaceholder" /> - + - - diff --git a/app/javascript/components/benchmarks/RuleOverview.vue b/app/javascript/components/benchmarks/RuleOverview.vue index 3a7586d84..72830da35 100644 --- a/app/javascript/components/benchmarks/RuleOverview.vue +++ b/app/javascript/components/benchmarks/RuleOverview.vue @@ -22,18 +22,17 @@
  • Rule ID: - {{ showFullRuleId ? selectedRule.rule_id : truncatedRuleId }} - +
  • @@ -48,18 +47,17 @@
  • - {{ showLegacyIds ? "▾" : "▸" }} Legacy IDs - +
    Vuln ID: {{ selectedRule.vuln_id }} @@ -184,5 +182,3 @@ export default { }, }; - - diff --git a/app/javascript/components/components/ComponentCard.vue b/app/javascript/components/components/ComponentCard.vue index 7ffa5f8c0..bbac49888 100644 --- a/app/javascript/components/components/ComponentCard.vue +++ b/app/javascript/components/components/ComponentCard.vue @@ -155,7 +155,6 @@ - - diff --git a/app/javascript/components/shared/ExportModal.vue b/app/javascript/components/shared/ExportModal.vue index 2b57129f3..afeede2ef 100644 --- a/app/javascript/components/shared/ExportModal.vue +++ b/app/javascript/components/shared/ExportModal.vue @@ -2,7 +2,7 @@
    - + DISA Excel @@ -31,7 +31,7 @@
    - +
    @@ -68,7 +68,7 @@ + - - - POA&M Available - - + + - - - - - {{ validFeedback["poam"] }} - - - {{ invalidFeedback["poam"] }} - - + + - - - - - {{ validFeedback["severity_override_guidance"] }} - - - {{ invalidFeedback["severity_override_guidance"] }} - - + + - - - - - {{ validFeedback["potential_impacts"] }} - - - {{ invalidFeedback["potential_impacts"] }} - - + + - - - - - {{ validFeedback["third_party_tools"] }} - - - {{ invalidFeedback["third_party_tools"] }} - - + + - - + - - - - {{ validFeedback["mitigation_control"] }} - - - {{ invalidFeedback["mitigation_control"] }} - - + + - - - - - {{ validFeedback["responsibility"] }} - - - {{ invalidFeedback["responsibility"] }} - - + + - - - - - {{ validFeedback["ia_controls"] }} - - - {{ invalidFeedback["ia_controls"] }} - - + +
    diff --git a/app/javascript/components/shared/History.vue b/app/javascript/components/shared/History.vue index 24a7fb7fc..07f9d1df8 100644 --- a/app/javascript/components/shared/History.vue +++ b/app/javascript/components/shared/History.vue @@ -122,12 +122,28 @@ export default { return `was ${changes.new_value ? "promoted to" : "demoted from"} admin`; } else if (changes.field == "locked_at") { return `account was ${changes.new_value ? "locked" : "unlocked"}`; + } else if (changes.field == "locked_fields") { + return this.computeLockedFieldsText(changes); } else { return `${changes.field} was updated from ${this.prettifyObjects( changes.prev_value, )} to ${this.prettifyObjects(changes.new_value)}`; } }, + computeLockedFieldsText: function (changes) { + const prev = changes.prev_value || {}; + const next = changes.new_value || {}; + const prevKeys = typeof prev === "object" ? Object.keys(prev) : []; + const nextKeys = typeof next === "object" ? Object.keys(next) : []; + const locked = nextKeys.filter((k) => !prevKeys.includes(k)); + const unlocked = prevKeys.filter((k) => !nextKeys.includes(k)); + const parts = []; + if (locked.length > 0) parts.push(`locked: ${locked.join(", ")}`); + if (unlocked.length > 0) parts.push(`unlocked: ${unlocked.join(", ")}`); + return parts.length > 0 + ? `section locks updated (${parts.join("; ")})` + : "section locks updated"; + }, prettifyObjects: function (value) { if (typeof value === "object") { return JSON.stringify(value, null, 4); diff --git a/app/javascript/composables/ruleFieldConfig.js b/app/javascript/composables/ruleFieldConfig.js index 944f2e35f..7f6724355 100644 --- a/app/javascript/composables/ruleFieldConfig.js +++ b/app/javascript/composables/ruleFieldConfig.js @@ -173,7 +173,7 @@ export const LOCKABLE_SECTIONS = { Severity: ["rule_severity", "severity_override_guidance"], Status: ["status", "status_justification"], Fix: ["fixtext", "fix_id", "fixtext_fixref"], - Check: ["content"], + Check: ["content", "system", "content_ref_name", "content_ref_href"], "Vulnerability Discussion": ["vuln_discussion"], "DISA Metadata": [ "documentable", @@ -191,4 +191,12 @@ export const LOCKABLE_SECTIONS = { ], "Vendor Comments": ["vendor_comments"], "Artifact Description": ["artifact_description"], + "XCCDF Metadata": ["version", "rule_weight", "ident", "ident_system"], }; + +// Reverse lookup: field name → section name (computed once at import time) +export const FIELD_TO_SECTION = Object.fromEntries( + Object.entries(LOCKABLE_SECTIONS).flatMap(([section, fields]) => + fields.map((field) => [field, section]), + ), +); diff --git a/app/javascript/composables/useRuleFormFields.js b/app/javascript/composables/useRuleFormFields.js index d8fbe4e5c..da235c6e6 100644 --- a/app/javascript/composables/useRuleFormFields.js +++ b/app/javascript/composables/useRuleFormFields.js @@ -97,19 +97,26 @@ export function useRuleFormFields(rule, advancedMode, options = {}) { if (!result.disabled.includes("fixtext")) result.disabled.push("fixtext"); } + // Inject section-locked fields into disabled array + injectLockedFields(result); + return result; }); // ─── DISA description fields ────────────────────────────── const disaDescriptionFields = computed(() => { const config = getStatusConfig(effectiveStatus.value); - return buildFieldSet(config.disa, advancedMode.value); + const result = buildFieldSet(config.disa, advancedMode.value); + injectLockedFields(result); + return result; }); // ─── Check form fields ──────────────────────────────────── const checkFormFields = computed(() => { const config = getStatusConfig(effectiveStatus.value); - return buildFieldSet(config.check, advancedMode.value); + const result = buildFieldSet(config.check, advancedMode.value); + injectLockedFields(result); + return result; }); // ─── Section visibility ─────────────────────────────────── @@ -125,14 +132,13 @@ export function useRuleFormFields(rule, advancedMode, options = {}) { return hasAdvancedDisa || hasAdvancedRule; }); - // ─── Granular locking stubs (Phase 5) ───────────────────── + // ─── Per-section locking ───────────────────────────────── function isFieldLocked(fieldName) { const r = rule.value; - if (!r.locked_fields) return false; - // Find which section this field belongs to - for (const [, fields] of Object.entries(LOCKABLE_SECTIONS)) { - if (fields.includes(fieldName)) { - return !!r.locked_fields[fieldName]; + if (!r.locked_fields || r.locked) return false; + for (const [section, fields] of Object.entries(LOCKABLE_SECTIONS)) { + if (fields.includes(fieldName) && r.locked_fields[section]) { + return true; } } return false; @@ -143,6 +149,40 @@ export function useRuleFormFields(rule, advancedMode, options = {}) { return !isFieldLocked(fieldName); } + // ─── Field state CSS class ────────────────────────────── + // Returns the CSS class for visual state indication on form groups. + // Priority: section-locked > under-review > whole-locked > none + function fieldStateClass(fieldName) { + const r = rule.value; + if (isFieldLocked(fieldName)) return "field-state--section-locked"; + if (r.review_requestor_id) return "field-state--under-review"; + if (r.locked) return "field-state--whole-locked"; + return ""; + } + + // Which field state is active for the entire rule (for legend display) + const activeFieldStates = computed(() => { + const r = rule.value; + const states = []; + if (r.locked_fields && Object.keys(r.locked_fields).length > 0 && !r.locked) { + states.push("section-locked"); + } + if (r.review_requestor_id) states.push("under-review"); + if (r.locked) states.push("whole-locked"); + return states; + }); + + // Inject section-locked fields into disabled arrays + function injectLockedFields(result) { + const r = rule.value; + if (!r.locked_fields || r.locked) return; + for (const fieldName of result.displayed) { + if (isFieldLocked(fieldName) && !result.disabled.includes(fieldName)) { + result.disabled.push(fieldName); + } + } + } + return { // Field configs ruleFormFields, @@ -167,5 +207,9 @@ export function useRuleFormFields(rule, advancedMode, options = {}) { // Granular locking isFieldLocked, isFieldEditable, + + // Field state visualization + fieldStateClass, + activeFieldStates, }; } diff --git a/app/javascript/constants/terminology.js b/app/javascript/constants/terminology.js index d11c5e8fe..e2a2a62e7 100644 --- a/app/javascript/constants/terminology.js +++ b/app/javascript/constants/terminology.js @@ -128,6 +128,18 @@ export const MESSAGE_LABELS = { revertHistoryTitle: `Revert ${RULE_TERM.singular} History`, }; +// Review action descriptions — maps review action strings to display labels. +// Used in RulesCodeEditorView, ProjectComponent, RuleReviews. +export const ACTION_DESCRIPTIONS = { + comment: "Commented", + request_review: "Requested Review", + revoke_review_request: "Revoked Request for Review", + request_changes: "Requested Changes", + approve: "Approved", + lock_control: "Locked", + unlock_control: "Unlocked", +}; + // Role descriptions (used in NewMembership) // Order matches available_roles: viewer, author, reviewer, admin export const ROLE_DESCRIPTIONS = [ From 11b4490912a914d16469309495688290b8a1404a Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Fri, 20 Feb 2026 17:54:33 -0500 Subject: [PATCH 297/428] refactor: replace dead Stig components with RuleFormGroup Delete StigRuleDetails.vue and StigRuleOverview.vue (replaced by BenchmarkViewer). Migrate RuleDetails.vue to RuleFormGroup. Authored by: Aaron Lippold --- .../components/benchmarks/RuleDetails.vue | 92 +++++++++--------- .../components/stigs/StigRuleDetails.vue | 94 ------------------- .../components/stigs/StigRuleOverview.vue | 60 ------------ 3 files changed, 49 insertions(+), 197 deletions(-) delete mode 100644 app/javascript/components/stigs/StigRuleDetails.vue delete mode 100644 app/javascript/components/stigs/StigRuleOverview.vue diff --git a/app/javascript/components/benchmarks/RuleDetails.vue b/app/javascript/components/benchmarks/RuleDetails.vue index 3e8421e93..51a92ebc0 100644 --- a/app/javascript/components/benchmarks/RuleDetails.vue +++ b/app/javascript/components/benchmarks/RuleDetails.vue @@ -30,48 +30,50 @@ /> - -
    @@ -81,10 +83,11 @@ - - diff --git a/app/javascript/components/stigs/StigRuleOverview.vue b/app/javascript/components/stigs/StigRuleOverview.vue deleted file mode 100644 index 0d4d449c4..000000000 --- a/app/javascript/components/stigs/StigRuleOverview.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - From e2da84299cbb48a2384d75194946bc386dcc345c Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Fri, 20 Feb 2026 17:54:49 -0500 Subject: [PATCH 298/428] test: section locking and mitigations XOR toggle tests Add composable tests for injectLockedFields, fieldStateClass, and activeFieldStates. Add model and request specs for section lock endpoints. Expand DisaRuleDescriptionForm specs for mitigations XOR visibility. Update RuleDetails and UnifiedRuleForm specs. Authored by: Aaron Lippold --- .../components/benchmarks/RuleDetails.spec.js | 37 +- .../forms/DisaRuleDescriptionForm.spec.js | 240 +++ .../components/rules/forms/RuleForm.spec.js | 14 +- .../rules/forms/UnifiedRuleForm.spec.js | 60 +- .../composables/useRuleFormFields.spec.js | 1681 ++++++++++------- spec/models/rule_section_locks_spec.rb | 83 + spec/requests/rule_section_locks_spec.rb | 181 ++ 7 files changed, 1573 insertions(+), 723 deletions(-) create mode 100644 spec/models/rule_section_locks_spec.rb create mode 100644 spec/requests/rule_section_locks_spec.rb diff --git a/spec/javascript/components/benchmarks/RuleDetails.spec.js b/spec/javascript/components/benchmarks/RuleDetails.spec.js index f18b451fd..bb1f673e1 100644 --- a/spec/javascript/components/benchmarks/RuleDetails.spec.js +++ b/spec/javascript/components/benchmarks/RuleDetails.spec.js @@ -8,16 +8,16 @@ import RuleDetails from "@/components/benchmarks/RuleDetails.vue"; * * REQUIREMENTS: * - * 1. GENERIC (works for STIG and SRG): - * - Accepts type prop ('stig' | 'srg') + * 1. GENERIC (works for STIG, SRG, and Component): + * - Accepts type prop ('stig' | 'srg' | 'component') * - Displays rule title * - Renders vuln discussion form * - Renders check form - * - Renders fix text - * - Renders vendor comments if present + * - Renders fix text via RuleFormGroup + * - Renders vendor comments via RuleFormGroup if present * * 2. NO TYPE-SPECIFIC DISPLAY: - * - Component structure is the same for both types + * - Component structure is the same for all types * - All fields are generic (rule has same structure) * * 3. DISABLED FORMS: @@ -84,6 +84,7 @@ describe("RuleDetails", () => { const validator = RuleDetails.props.type.validator; expect(validator("stig")).toBe(true); expect(validator("srg")).toBe(true); + expect(validator("component")).toBe(true); expect(validator("invalid")).toBe(false); }); }); @@ -97,20 +98,25 @@ describe("RuleDetails", () => { expect(wrapper.text()).toContain("Test Rule Title"); }); - it("renders fix text field", () => { + it("renders fix text via RuleFormGroup", () => { wrapper = createWrapper(); - // Should contain "Fix" label - expect(wrapper.text()).toContain("Fix"); + const groups = wrapper.findAllComponents({ name: "RuleFormGroup" }); + const fixtextGroup = groups.wrappers.find((w) => w.props("fieldName") === "fixtext"); + expect(fixtextGroup).toBeTruthy(); }); - it("renders vendor comments when present", () => { + it("renders vendor comments via RuleFormGroup when present", () => { wrapper = createWrapper({ selectedRule: mockRule }); - expect(wrapper.text()).toContain("Vendor Comments"); + const groups = wrapper.findAllComponents({ name: "RuleFormGroup" }); + const vcGroup = groups.wrappers.find((w) => w.props("fieldName") === "vendor_comments"); + expect(vcGroup).toBeTruthy(); }); it("does not render vendor comments when absent", () => { wrapper = createWrapper({ selectedRule: mockRuleWithoutVendorComments }); - expect(wrapper.text()).not.toContain("Vendor Comments"); + const groups = wrapper.findAllComponents({ name: "RuleFormGroup" }); + const vcGroup = groups.wrappers.find((w) => w.props("fieldName") === "vendor_comments"); + expect(vcGroup).toBeFalsy(); }); }); @@ -156,13 +162,15 @@ describe("RuleDetails", () => { it("renders identically for STIG type", () => { const stigWrapper = createWrapper({ type: "stig" }); expect(stigWrapper.text()).toContain("Test Rule Title"); - expect(stigWrapper.text()).toContain("Fix"); + const groups = stigWrapper.findAllComponents({ name: "RuleFormGroup" }); + expect(groups.wrappers.find((w) => w.props("fieldName") === "fixtext")).toBeTruthy(); }); it("renders identically for SRG type", () => { const srgWrapper = createWrapper({ type: "srg" }); expect(srgWrapper.text()).toContain("Test Rule Title"); - expect(srgWrapper.text()).toContain("Fix"); + const groups = srgWrapper.findAllComponents({ name: "RuleFormGroup" }); + expect(groups.wrappers.find((w) => w.props("fieldName") === "fixtext")).toBeTruthy(); }); }); @@ -171,7 +179,6 @@ describe("RuleDetails", () => { // ========================================== describe("null/undefined rule handling", () => { it("accepts null selectedRule without Vue warnings", () => { - // Capture console errors const errors = []; const originalError = console.error; console.error = (...args) => errors.push(args.join(" ")); @@ -184,10 +191,8 @@ describe("RuleDetails", () => { }, }); - // Restore console.error console.error = originalError; - // Should not emit Vue prop validation errors const propErrors = errors.filter( (e) => e.includes("Invalid prop") || e.includes("type check failed"), ); diff --git a/spec/javascript/components/rules/forms/DisaRuleDescriptionForm.spec.js b/spec/javascript/components/rules/forms/DisaRuleDescriptionForm.spec.js index cfe013b36..060619d71 100644 --- a/spec/javascript/components/rules/forms/DisaRuleDescriptionForm.spec.js +++ b/spec/javascript/components/rules/forms/DisaRuleDescriptionForm.spec.js @@ -63,6 +63,246 @@ describe("DisaRuleDescriptionForm", () => { }); }; + // REQUIREMENT: Mitigations and Mitigation Control text fields should only + // be visible when the "Mitigations Available" toggle is ON. POA&M toggle + // should only appear when Mitigations Available is OFF (XOR behavior). + // POA&M text field should only appear when POA&M Available is ON. + + describe("mitigations/POA&M conditional visibility", () => { + it("hides mitigations textarea when mitigations_available is false", () => { + const wrapper = createWrapper(); // mitigations_available defaults to false + const field = wrapper.find('[id^="ruleEditor-disa_rule_description-mitigations-group"]'); + expect(field.exists()).toBe(false); + }); + + it("shows mitigations textarea when mitigations_available is true", () => { + const wrapper = createWrapper({ + description: { + _destroy: false, + documentable: false, + vuln_discussion: "", + false_positives: "", + false_negatives: "", + mitigations_available: true, + mitigations: "", + poam_available: false, + poam: "", + severity_override_guidance: "", + potential_impacts: "", + third_party_tools: "", + mitigation_control: "", + responsibility: "", + ia_controls: "", + }, + }); + const field = wrapper.find('[id^="ruleEditor-disa_rule_description-mitigations-group"]'); + expect(field.exists()).toBe(true); + }); + + it("hides mitigation_control when mitigations_available is false", () => { + const wrapper = createWrapper(); + const field = wrapper.find( + '[id^="ruleEditor-disa_rule_description-mitigation_control-group"]', + ); + expect(field.exists()).toBe(false); + }); + + it("shows mitigation_control when mitigations_available is true", () => { + const wrapper = createWrapper({ + description: { + _destroy: false, + documentable: false, + vuln_discussion: "", + false_positives: "", + false_negatives: "", + mitigations_available: true, + mitigations: "", + poam_available: false, + poam: "", + severity_override_guidance: "", + potential_impacts: "", + third_party_tools: "", + mitigation_control: "", + responsibility: "", + ia_controls: "", + }, + }); + const field = wrapper.find( + '[id^="ruleEditor-disa_rule_description-mitigation_control-group"]', + ); + expect(field.exists()).toBe(true); + }); + + it("hides POA&M toggle when mitigations_available is true", () => { + const wrapper = createWrapper({ + description: { + _destroy: false, + documentable: false, + vuln_discussion: "", + false_positives: "", + false_negatives: "", + mitigations_available: true, + mitigations: "", + poam_available: false, + poam: "", + severity_override_guidance: "", + potential_impacts: "", + third_party_tools: "", + mitigation_control: "", + responsibility: "", + ia_controls: "", + }, + }); + const field = wrapper.find('[id^="ruleEditor-disa_rule_description-poam_available-group"]'); + expect(field.exists()).toBe(false); + }); + + it("shows POA&M toggle when mitigations_available is false", () => { + const wrapper = createWrapper(); + const field = wrapper.find('[id^="ruleEditor-disa_rule_description-poam_available-group"]'); + expect(field.exists()).toBe(true); + }); + + it("hides POA&M textarea when poam_available is false", () => { + const wrapper = createWrapper(); + const field = wrapper.find('[id^="ruleEditor-disa_rule_description-poam-group"]'); + expect(field.exists()).toBe(false); + }); + + it("shows POA&M textarea when poam_available is true and mitigations_available is false", () => { + const wrapper = createWrapper({ + description: { + _destroy: false, + documentable: false, + vuln_discussion: "", + false_positives: "", + false_negatives: "", + mitigations_available: false, + mitigations: "", + poam_available: true, + poam: "", + severity_override_guidance: "", + potential_impacts: "", + third_party_tools: "", + mitigation_control: "", + responsibility: "", + ia_controls: "", + }, + }); + const field = wrapper.find('[id^="ruleEditor-disa_rule_description-poam-group"]'); + expect(field.exists()).toBe(true); + }); + + it("hides POA&M textarea when poam_available is true but mitigations_available is also true", () => { + const wrapper = createWrapper({ + description: { + _destroy: false, + documentable: false, + vuln_discussion: "", + false_positives: "", + false_negatives: "", + mitigations_available: true, + mitigations: "", + poam_available: true, + poam: "", + severity_override_guidance: "", + potential_impacts: "", + third_party_tools: "", + mitigation_control: "", + responsibility: "", + ia_controls: "", + }, + }); + // POA&M toggle itself should be hidden (XOR) + const toggle = wrapper.find('[id^="ruleEditor-disa_rule_description-poam_available-group"]'); + expect(toggle.exists()).toBe(false); + // POA&M text should also be hidden + const text = wrapper.find('[id^="ruleEditor-disa_rule_description-poam-group"]'); + expect(text.exists()).toBe(false); + }); + }); + + // REQUIREMENT: Fields that are NOT dependent on mitigations/POA&M toggles + // should always render when included in the displayed list. + + describe("always-visible fields (not toggle-dependent)", () => { + const alwaysVisibleFields = [ + "potential_impacts", + "third_party_tools", + "responsibility", + "ia_controls", + "severity_override_guidance", + ]; + + for (const fieldName of alwaysVisibleFields) { + it(`shows ${fieldName} regardless of mitigations_available state`, () => { + // Test with mitigations OFF + const wrapperOff = createWrapper(); + const fieldOff = wrapperOff.find( + `[id^="ruleEditor-disa_rule_description-${fieldName}-group"]`, + ); + expect(fieldOff.exists()).toBe(true); + + // Test with mitigations ON + const wrapperOn = createWrapper({ + description: { + _destroy: false, + documentable: false, + vuln_discussion: "", + false_positives: "", + false_negatives: "", + mitigations_available: true, + mitigations: "", + poam_available: false, + poam: "", + severity_override_guidance: "", + potential_impacts: "", + third_party_tools: "", + mitigation_control: "", + responsibility: "", + ia_controls: "", + }, + }); + const fieldOn = wrapperOn.find( + `[id^="ruleEditor-disa_rule_description-${fieldName}-group"]`, + ); + expect(fieldOn.exists()).toBe(true); + }); + } + }); + + // REQUIREMENT: All fields should have descriptive tooltips to help users + // understand what data is expected. No tooltip should be null for + // fields that accept user input. + + describe("tooltips", () => { + it("provides tooltips for all toggle and text fields", () => { + const wrapper = createWrapper(); + const vm = wrapper.vm; + + // These should all have non-null tooltips + const fieldsRequiringTooltips = [ + "vuln_discussion", + "false_positives", + "false_negatives", + "mitigations_available", + "poam_available", + "potential_impacts", + "third_party_tools", + "mitigation_control", + "responsibility", + "ia_controls", + "severity_override_guidance", + "documentable", + ]; + + for (const field of fieldsRequiringTooltips) { + expect(vm.tooltips[field]).not.toBeNull(); + expect(vm.tooltips[field]).toBeTruthy(); + } + }); + }); + // REQUIREMENT: The severity_override_guidance field label must read // "Severity Override Guidance" to match the DISA STIG terminology. // The database column is named severity_override_guidance, the diff --git a/spec/javascript/components/rules/forms/RuleForm.spec.js b/spec/javascript/components/rules/forms/RuleForm.spec.js index 65183c9f7..362cda0d8 100644 --- a/spec/javascript/components/rules/forms/RuleForm.spec.js +++ b/spec/javascript/components/rules/forms/RuleForm.spec.js @@ -108,7 +108,7 @@ describe("RuleForm", () => { it("renders severity dropdown when in displayed", () => { wrapper = createWrapper(); - expect(wrapper.find('[id^="ruleEditor-rule_severity-top-group-"]').exists()).toBe(true); + expect(wrapper.find('[id^="ruleEditor-rule_severity-group-"]').exists()).toBe(true); }); it("renders title field when in displayed", () => { @@ -203,7 +203,7 @@ describe("RuleForm", () => { wrapper = createWrapper({ fields: { displayed: ["status", "rule_severity", "title"], disabled: ["rule_severity"] }, }); - const select = wrapper.find('select[id^="ruleEditor-rule_severity-top-"]'); + const select = wrapper.find('select[id^="ruleEditor-rule_severity-"]'); expect(select.element.disabled).toBe(true); }); @@ -211,7 +211,7 @@ describe("RuleForm", () => { wrapper = createWrapper({ fields: { displayed: ["status", "rule_severity", "title"], disabled: [] }, }); - const select = wrapper.find('select[id^="ruleEditor-rule_severity-top-"]'); + const select = wrapper.find('select[id^="ruleEditor-rule_severity-"]'); expect(select.element.disabled).toBe(false); }); @@ -240,9 +240,7 @@ describe("RuleForm", () => { fields: { displayed: ["status", "rule_severity", "title"], disabled: [] }, }); expect(wrapper.find('select[id^="ruleEditor-status-"]').element.disabled).toBe(true); - expect(wrapper.find('select[id^="ruleEditor-rule_severity-top-"]').element.disabled).toBe( - true, - ); + expect(wrapper.find('select[id^="ruleEditor-rule_severity-"]').element.disabled).toBe(true); expect(wrapper.find('textarea[id^="ruleEditor-title-"]').element.disabled).toBe(true); }); }); @@ -256,7 +254,7 @@ describe("RuleForm", () => { it("displays the correct IA Control value", () => { wrapper = createWrapper(); - const iaInput = wrapper.find('input[id^="ruleEditor-ia_control-"]'); + const iaInput = wrapper.find('input[id^="ruleEditor-nist_control_family-"]'); expect(iaInput.element.value).toBe("AC-2 (1)"); }); @@ -268,7 +266,7 @@ describe("RuleForm", () => { it("IA Control and CCI inputs are readonly", () => { wrapper = createWrapper(); - const iaInput = wrapper.find('input[id^="ruleEditor-ia_control-"]'); + const iaInput = wrapper.find('input[id^="ruleEditor-nist_control_family-"]'); const cciInput = wrapper.find('input[id^="ruleEditor-cci-"]'); expect(iaInput.attributes("readonly")).toBeDefined(); expect(cciInput.attributes("readonly")).toBeDefined(); diff --git a/spec/javascript/components/rules/forms/UnifiedRuleForm.spec.js b/spec/javascript/components/rules/forms/UnifiedRuleForm.spec.js index bd6371a82..f343bbef4 100644 --- a/spec/javascript/components/rules/forms/UnifiedRuleForm.spec.js +++ b/spec/javascript/components/rules/forms/UnifiedRuleForm.spec.js @@ -180,29 +180,20 @@ describe("UnifiedRuleForm", () => { ); }); - it("shows collapsible DISA section for Does Not Meet (has advanced DISA additions)", () => { + it("passes DISA fields inline for Does Not Meet advanced (includes advanced additions)", () => { wrapper = createWrapper({ status: "Applicable - Does Not Meet" }, { advancedMode: true }); - // DNM has advancedDisplayed DISA fields so should get collapsible sections - expect(wrapper.findComponent({ name: "DisaRuleDescriptionForm" }).exists()).toBe(true); - const headings = wrapper.findAll("h2"); - const hasRuleDescriptionHeading = headings.wrappers.some( - (h) => h.text() === "Rule Description", - ); - expect(hasRuleDescriptionHeading).toBe(true); + const ruleForm = wrapper.findComponent({ name: "RuleForm" }); + expect(ruleForm.props("disa_fields")).toBeDefined(); + expect(ruleForm.props("disa_fields").displayed).toContain("mitigations"); + // Advanced additions present inline + expect(ruleForm.props("disa_fields").displayed).toContain("false_positives"); }); - it("does NOT show collapsible DISA section for NYD advanced (no advanced additions)", () => { + it("passes DISA fields inline for NYD advanced", () => { wrapper = createWrapper({ status: "Not Yet Determined" }, { advancedMode: true }); - // NYD has no advancedDisplayed entries, so disa_fields stay inline in RuleForm const ruleForm = wrapper.findComponent({ name: "RuleForm" }); expect(ruleForm.props("disa_fields")).toBeDefined(); expect(ruleForm.props("disa_fields").displayed).toContain("vuln_discussion"); - // No collapsible heading - const headings = wrapper.findAll("h2"); - const hasRuleDescriptionHeading = headings.wrappers.some( - (h) => h.text() === "Rule Description", - ); - expect(hasRuleDescriptionHeading).toBe(false); }); }); @@ -229,26 +220,24 @@ describe("UnifiedRuleForm", () => { expect(ruleForm.props("disa_fields")).toBeUndefined(); }); - it("does NOT pass disa_fields to RuleForm in advanced mode (collapsible section handles it)", () => { + it("passes disa_fields inline in advanced mode (no collapsible sections)", () => { wrapper = createWrapper({ status: "Applicable - Configurable" }, { advancedMode: true }); const ruleForm = wrapper.findComponent({ name: "RuleForm" }); - expect(ruleForm.props("disa_fields")).toBeUndefined(); + expect(ruleForm.props("disa_fields")).toBeDefined(); + expect(ruleForm.props("disa_fields").displayed).toContain("vuln_discussion"); + // Advanced fields included inline + expect(ruleForm.props("disa_fields").displayed).toContain("documentable"); }); - it("shows collapsible DISA section in advanced mode when DISA fields exist", () => { + it("no collapsible headings in any mode", () => { wrapper = createWrapper({ status: "Applicable - Configurable" }, { advancedMode: true }); - expect(wrapper.findComponent({ name: "DisaRuleDescriptionForm" }).exists()).toBe(true); - }); - - it("hides collapsible DISA section in basic mode", () => { - wrapper = createWrapper({ status: "Applicable - Configurable" }, { advancedMode: false }); - // In basic mode, DisaRuleDescriptionForm is rendered inside RuleForm (via disa_fields prop) - // The collapsible section with its own heading should NOT exist const headings = wrapper.findAll("h2"); const hasRuleDescriptionHeading = headings.wrappers.some( (h) => h.text() === "Rule Description", ); + const hasChecksHeading = headings.wrappers.some((h) => h.text() === "Checks"); expect(hasRuleDescriptionHeading).toBe(false); + expect(hasChecksHeading).toBe(false); }); }); @@ -275,29 +264,18 @@ describe("UnifiedRuleForm", () => { expect(ruleForm.props("check_fields")).toBeUndefined(); }); - it("does NOT pass check_fields to RuleForm in advanced mode (collapsible section handles it)", () => { + it("passes check_fields inline in advanced mode (no collapsible sections)", () => { wrapper = createWrapper({ status: "Applicable - Configurable" }, { advancedMode: true }); const ruleForm = wrapper.findComponent({ name: "RuleForm" }); - expect(ruleForm.props("check_fields")).toBeUndefined(); - }); - - it("shows collapsible Checks section in advanced mode for Configurable", () => { - wrapper = createWrapper({ status: "Applicable - Configurable" }, { advancedMode: true }); - const headings = wrapper.findAll("h2"); - const hasChecksHeading = headings.wrappers.some((h) => h.text() === "Checks"); - expect(hasChecksHeading).toBe(true); + expect(ruleForm.props("check_fields")).toBeDefined(); + expect(ruleForm.props("check_fields").displayed).toContain("content"); }); - it("keeps check fields inline for NYD advanced mode (no collapsible section)", () => { + it("passes check_fields inline for NYD advanced mode", () => { wrapper = createWrapper({ status: "Not Yet Determined" }, { advancedMode: true }); const ruleForm = wrapper.findComponent({ name: "RuleForm" }); - // NYD has no advanced additions, so check fields stay inline in RuleForm expect(ruleForm.props("check_fields")).toBeDefined(); expect(ruleForm.props("check_fields").displayed).toContain("content"); - // No collapsible heading - const headings = wrapper.findAll("h2"); - const hasChecksHeading = headings.wrappers.some((h) => h.text() === "Checks"); - expect(hasChecksHeading).toBe(false); }); }); diff --git a/spec/javascript/composables/useRuleFormFields.spec.js b/spec/javascript/composables/useRuleFormFields.spec.js index 7e60bec6f..5d4670063 100644 --- a/spec/javascript/composables/useRuleFormFields.spec.js +++ b/spec/javascript/composables/useRuleFormFields.spec.js @@ -8,787 +8,1152 @@ * R4: Granular locking (stub API — isFieldLocked, isFieldEditable) * R5: IA Control/CCI always visible (handled in component, not composable) */ -import { describe, it, expect } from 'vitest' -import { ref } from 'vue' -import { useRuleFormFields } from '@/composables/useRuleFormFields' +import { describe, it, expect } from "vitest"; +import { ref } from "vue"; +import { useRuleFormFields } from "@/composables/useRuleFormFields"; // Helper to create a rule object with sensible defaults function makeRule(overrides = {}) { return { - status: 'Applicable - Configurable', - rule_severity: 'medium', + status: "Applicable - Configurable", + rule_severity: "medium", locked: false, review_requestor_id: null, satisfied_by: [], srg_rule_attributes: { - rule_severity: 'medium', + rule_severity: "medium", }, - disa_rule_descriptions_attributes: [{ vuln_discussion: '' }], - checks_attributes: [{ content: '' }], - nist_control_family: 'AC-2 (1)', - ident: 'CCI-000015', + disa_rule_descriptions_attributes: [{ vuln_discussion: "" }], + checks_attributes: [{ content: "" }], + nist_control_family: "AC-2 (1)", + ident: "CCI-000015", ...overrides, - } + }; } -describe('useRuleFormFields', () => { +describe("useRuleFormFields", () => { // ─── effectiveStatus ─────────────────────────────────────── - describe('effectiveStatus', () => { - it('returns rule.status when no satisfied_by', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { effectiveStatus } = useRuleFormFields(rule, ref(false)) - expect(effectiveStatus.value).toBe('Not Applicable') - }) + describe("effectiveStatus", () => { + it("returns rule.status when no satisfied_by", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { effectiveStatus } = useRuleFormFields(rule, ref(false)); + expect(effectiveStatus.value).toBe("Not Applicable"); + }); it('forces "Applicable - Configurable" when satisfied_by is non-empty', () => { - const rule = ref(makeRule({ - status: 'Not Yet Determined', - satisfied_by: [{ id: 1, fixtext: 'parent fix' }], - })) - const { effectiveStatus } = useRuleFormFields(rule, ref(false)) - expect(effectiveStatus.value).toBe('Applicable - Configurable') - }) - }) + const rule = ref( + makeRule({ + status: "Not Yet Determined", + satisfied_by: [{ id: 1, fixtext: "parent fix" }], + }), + ); + const { effectiveStatus } = useRuleFormFields(rule, ref(false)); + expect(effectiveStatus.value).toBe("Applicable - Configurable"); + }); + }); // ─── isFormDisabled ──────────────────────────────────────── - describe('isFormDisabled', () => { - it('returns false for normal editable rule', () => { - const rule = ref(makeRule()) - const { isFormDisabled } = useRuleFormFields(rule, ref(false)) - expect(isFormDisabled.value).toBe(false) - }) - - it('returns true when rule is locked', () => { - const rule = ref(makeRule({ locked: true })) - const { isFormDisabled } = useRuleFormFields(rule, ref(false)) - expect(isFormDisabled.value).toBe(true) - }) - - it('returns true when rule has review_requestor_id', () => { - const rule = ref(makeRule({ review_requestor_id: 42 })) - const { isFormDisabled } = useRuleFormFields(rule, ref(false)) - expect(isFormDisabled.value).toBe(true) - }) - - it('returns true when readOnly option is set', () => { - const rule = ref(makeRule()) - const { isFormDisabled } = useRuleFormFields(rule, ref(false), { readOnly: ref(true) }) - expect(isFormDisabled.value).toBe(true) - }) - - it('does NOT disable when satisfied_by is set (R3: no whole-form disable)', () => { - const rule = ref(makeRule({ satisfied_by: [{ id: 1 }] })) - const { isFormDisabled } = useRuleFormFields(rule, ref(false)) - expect(isFormDisabled.value).toBe(false) - }) - }) + describe("isFormDisabled", () => { + it("returns false for normal editable rule", () => { + const rule = ref(makeRule()); + const { isFormDisabled } = useRuleFormFields(rule, ref(false)); + expect(isFormDisabled.value).toBe(false); + }); + + it("returns true when rule is locked", () => { + const rule = ref(makeRule({ locked: true })); + const { isFormDisabled } = useRuleFormFields(rule, ref(false)); + expect(isFormDisabled.value).toBe(true); + }); + + it("returns true when rule has review_requestor_id", () => { + const rule = ref(makeRule({ review_requestor_id: 42 })); + const { isFormDisabled } = useRuleFormFields(rule, ref(false)); + expect(isFormDisabled.value).toBe(true); + }); + + it("returns true when readOnly option is set", () => { + const rule = ref(makeRule()); + const { isFormDisabled } = useRuleFormFields(rule, ref(false), { readOnly: ref(true) }); + expect(isFormDisabled.value).toBe(true); + }); + + it("does NOT disable when satisfied_by is set (R3: no whole-form disable)", () => { + const rule = ref(makeRule({ satisfied_by: [{ id: 1 }] })); + const { isFormDisabled } = useRuleFormFields(rule, ref(false)); + expect(isFormDisabled.value).toBe(false); + }); + }); // ─── forceEnableAdditionalQuestions ──────────────────────── - describe('forceEnableAdditionalQuestions', () => { - it('returns true for normal editable rule', () => { - const rule = ref(makeRule()) - const { forceEnableAdditionalQuestions } = useRuleFormFields(rule, ref(false)) - expect(forceEnableAdditionalQuestions.value).toBe(true) - }) - - it('returns false when locked', () => { - const rule = ref(makeRule({ locked: true })) - const { forceEnableAdditionalQuestions } = useRuleFormFields(rule, ref(false)) - expect(forceEnableAdditionalQuestions.value).toBe(false) - }) - - it('returns false when under review', () => { - const rule = ref(makeRule({ review_requestor_id: 5 })) - const { forceEnableAdditionalQuestions } = useRuleFormFields(rule, ref(false)) - expect(forceEnableAdditionalQuestions.value).toBe(false) - }) - }) + describe("forceEnableAdditionalQuestions", () => { + it("returns true for normal editable rule", () => { + const rule = ref(makeRule()); + const { forceEnableAdditionalQuestions } = useRuleFormFields(rule, ref(false)); + expect(forceEnableAdditionalQuestions.value).toBe(true); + }); + + it("returns false when locked", () => { + const rule = ref(makeRule({ locked: true })); + const { forceEnableAdditionalQuestions } = useRuleFormFields(rule, ref(false)); + expect(forceEnableAdditionalQuestions.value).toBe(false); + }); + + it("returns false when under review", () => { + const rule = ref(makeRule({ review_requestor_id: 5 })); + const { forceEnableAdditionalQuestions } = useRuleFormFields(rule, ref(false)); + expect(forceEnableAdditionalQuestions.value).toBe(false); + }); + }); // ─── severityChanged / severityEditable / showSeverityOverride ─ - describe('severity override detection (R2)', () => { - it('severityChanged is false when severity matches SRG default', () => { - const rule = ref(makeRule({ rule_severity: 'medium', srg_rule_attributes: { rule_severity: 'medium' } })) - const { severityChanged } = useRuleFormFields(rule, ref(false)) - expect(severityChanged.value).toBe(false) - }) - - it('severityChanged is true when severity differs from SRG default', () => { - const rule = ref(makeRule({ rule_severity: 'high', srg_rule_attributes: { rule_severity: 'medium' } })) - const { severityChanged } = useRuleFormFields(rule, ref(false)) - expect(severityChanged.value).toBe(true) - }) - - it('severityChanged is false when srg_rule_attributes is null', () => { - const rule = ref(makeRule({ srg_rule_attributes: null })) - const { severityChanged } = useRuleFormFields(rule, ref(false)) - expect(severityChanged.value).toBe(false) - }) - - it('severityEditable is true for Configurable', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { severityEditable } = useRuleFormFields(rule, ref(false)) - expect(severityEditable.value).toBe(true) - }) - - it('severityEditable is true for Inherently Meets', () => { - const rule = ref(makeRule({ status: 'Applicable - Inherently Meets' })) - const { severityEditable } = useRuleFormFields(rule, ref(false)) - expect(severityEditable.value).toBe(true) - }) - - it('severityEditable is true for Does Not Meet', () => { - const rule = ref(makeRule({ status: 'Applicable - Does Not Meet' })) - const { severityEditable } = useRuleFormFields(rule, ref(false)) - expect(severityEditable.value).toBe(true) - }) - - it('severityEditable is false for Not Applicable', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { severityEditable } = useRuleFormFields(rule, ref(false)) - expect(severityEditable.value).toBe(false) - }) - - it('severityEditable is false for Not Yet Determined', () => { - const rule = ref(makeRule({ status: 'Not Yet Determined' })) - const { severityEditable } = useRuleFormFields(rule, ref(false)) - expect(severityEditable.value).toBe(false) - }) - - it('showSeverityOverride is true when severity changed + applicable status', () => { - const rule = ref(makeRule({ - status: 'Applicable - Configurable', - rule_severity: 'high', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { showSeverityOverride } = useRuleFormFields(rule, ref(false)) - expect(showSeverityOverride.value).toBe(true) - }) - - it('showSeverityOverride is false when severity matches SRG default', () => { - const rule = ref(makeRule({ - status: 'Applicable - Configurable', - rule_severity: 'medium', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { showSeverityOverride } = useRuleFormFields(rule, ref(false)) - expect(showSeverityOverride.value).toBe(false) - }) - - it('showSeverityOverride is false for Not Applicable even if severity changed', () => { - const rule = ref(makeRule({ - status: 'Not Applicable', - rule_severity: 'high', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { showSeverityOverride } = useRuleFormFields(rule, ref(false)) - expect(showSeverityOverride.value).toBe(false) - }) - - it('showSeverityOverride is false for Not Yet Determined even if severity changed', () => { - const rule = ref(makeRule({ - status: 'Not Yet Determined', - rule_severity: 'high', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { showSeverityOverride } = useRuleFormFields(rule, ref(false)) - expect(showSeverityOverride.value).toBe(false) - }) - }) + describe("severity override detection (R2)", () => { + it("severityChanged is false when severity matches SRG default", () => { + const rule = ref( + makeRule({ rule_severity: "medium", srg_rule_attributes: { rule_severity: "medium" } }), + ); + const { severityChanged } = useRuleFormFields(rule, ref(false)); + expect(severityChanged.value).toBe(false); + }); + + it("severityChanged is true when severity differs from SRG default", () => { + const rule = ref( + makeRule({ rule_severity: "high", srg_rule_attributes: { rule_severity: "medium" } }), + ); + const { severityChanged } = useRuleFormFields(rule, ref(false)); + expect(severityChanged.value).toBe(true); + }); + + it("severityChanged is false when srg_rule_attributes is null", () => { + const rule = ref(makeRule({ srg_rule_attributes: null })); + const { severityChanged } = useRuleFormFields(rule, ref(false)); + expect(severityChanged.value).toBe(false); + }); + + it("severityEditable is true for Configurable", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { severityEditable } = useRuleFormFields(rule, ref(false)); + expect(severityEditable.value).toBe(true); + }); + + it("severityEditable is true for Inherently Meets", () => { + const rule = ref(makeRule({ status: "Applicable - Inherently Meets" })); + const { severityEditable } = useRuleFormFields(rule, ref(false)); + expect(severityEditable.value).toBe(true); + }); + + it("severityEditable is true for Does Not Meet", () => { + const rule = ref(makeRule({ status: "Applicable - Does Not Meet" })); + const { severityEditable } = useRuleFormFields(rule, ref(false)); + expect(severityEditable.value).toBe(true); + }); + + it("severityEditable is false for Not Applicable", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { severityEditable } = useRuleFormFields(rule, ref(false)); + expect(severityEditable.value).toBe(false); + }); + + it("severityEditable is false for Not Yet Determined", () => { + const rule = ref(makeRule({ status: "Not Yet Determined" })); + const { severityEditable } = useRuleFormFields(rule, ref(false)); + expect(severityEditable.value).toBe(false); + }); + + it("showSeverityOverride is true when severity changed + applicable status", () => { + const rule = ref( + makeRule({ + status: "Applicable - Configurable", + rule_severity: "high", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { showSeverityOverride } = useRuleFormFields(rule, ref(false)); + expect(showSeverityOverride.value).toBe(true); + }); + + it("showSeverityOverride is false when severity matches SRG default", () => { + const rule = ref( + makeRule({ + status: "Applicable - Configurable", + rule_severity: "medium", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { showSeverityOverride } = useRuleFormFields(rule, ref(false)); + expect(showSeverityOverride.value).toBe(false); + }); + + it("showSeverityOverride is false for Not Applicable even if severity changed", () => { + const rule = ref( + makeRule({ + status: "Not Applicable", + rule_severity: "high", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { showSeverityOverride } = useRuleFormFields(rule, ref(false)); + expect(showSeverityOverride.value).toBe(false); + }); + + it("showSeverityOverride is false for Not Yet Determined even if severity changed", () => { + const rule = ref( + makeRule({ + status: "Not Yet Determined", + rule_severity: "high", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { showSeverityOverride } = useRuleFormFields(rule, ref(false)); + expect(showSeverityOverride.value).toBe(false); + }); + }); // ─── ruleFormFields: Basic mode ──────────────────────────── - describe('ruleFormFields - basic mode', () => { - const advancedMode = ref(false) + describe("ruleFormFields - basic mode", () => { + const advancedMode = ref(false); - it('Configurable: shows status, rule_severity, title, fixtext, vendor_comments', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) + it("Configurable: shows status, rule_severity, title, fixtext, vendor_comments", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'title', 'fixtext', 'vendor_comments']) - ) - expect(ruleFormFields.value.disabled).toEqual([]) - }) - - it('Not Yet Determined: shows status, rule_severity, title, fixtext; disables title, rule_severity, fixtext', () => { - const rule = ref(makeRule({ status: 'Not Yet Determined' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) + expect.arrayContaining(["status", "rule_severity", "title", "fixtext", "vendor_comments"]), + ); + expect(ruleFormFields.value.disabled).toEqual([]); + }); + + it("Not Yet Determined: shows status, rule_severity, title, fixtext; disables title, rule_severity, fixtext", () => { + const rule = ref(makeRule({ status: "Not Yet Determined" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'title', 'fixtext']) - ) + expect.arrayContaining(["status", "rule_severity", "title", "fixtext"]), + ); expect(ruleFormFields.value.disabled).toEqual( - expect.arrayContaining(['title', 'rule_severity', 'fixtext']) - ) - }) + expect.arrayContaining(["title", "rule_severity", "fixtext"]), + ); + }); - it('Inherently Meets: shows status, rule_severity, status_justification, artifact_description, vendor_comments', () => { - const rule = ref(makeRule({ status: 'Applicable - Inherently Meets' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) + it("Inherently Meets: shows status, rule_severity, status_justification, artifact_description, vendor_comments", () => { + const rule = ref(makeRule({ status: "Applicable - Inherently Meets" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'status_justification', 'artifact_description', 'vendor_comments']) - ) - expect(ruleFormFields.value.disabled).toEqual([]) - }) - - it('Does Not Meet: shows status, rule_severity, status_justification, vendor_comments', () => { - const rule = ref(makeRule({ status: 'Applicable - Does Not Meet' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) + expect.arrayContaining([ + "status", + "rule_severity", + "status_justification", + "artifact_description", + "vendor_comments", + ]), + ); + expect(ruleFormFields.value.disabled).toEqual([]); + }); + + it("Does Not Meet: shows status, rule_severity, status_justification, vendor_comments", () => { + const rule = ref(makeRule({ status: "Applicable - Does Not Meet" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'status_justification', 'vendor_comments']) - ) - expect(ruleFormFields.value.disabled).toEqual([]) - }) - - it('Not Applicable: shows status, rule_severity, status_justification, artifact_description, vendor_comments; disables rule_severity', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) + expect.arrayContaining([ + "status", + "rule_severity", + "status_justification", + "vendor_comments", + ]), + ); + expect(ruleFormFields.value.disabled).toEqual([]); + }); + + it("Not Applicable: shows status, rule_severity, status_justification, artifact_description, vendor_comments; disables rule_severity", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'status_justification', 'artifact_description', 'vendor_comments']) - ) - expect(ruleFormFields.value.disabled).toEqual( - expect.arrayContaining(['rule_severity']) - ) - }) - }) + expect.arrayContaining([ + "status", + "rule_severity", + "status_justification", + "artifact_description", + "vendor_comments", + ]), + ); + expect(ruleFormFields.value.disabled).toEqual(expect.arrayContaining(["rule_severity"])); + }); + }); // ─── ruleFormFields: Advanced mode ───────────────────────── - describe('ruleFormFields - advanced mode', () => { - const advancedMode = ref(true) - - it('Configurable: includes advanced fields like version, rule_weight, fix_id, ident', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) - const d = ruleFormFields.value.displayed - expect(d).toEqual(expect.arrayContaining([ - 'status', 'rule_severity', 'title', 'fixtext', 'vendor_comments', - 'status_justification', 'version', 'rule_weight', - 'artifact_description', 'fix_id', 'fixtext_fixref', - 'ident', 'ident_system', - ])) - }) - - it('Not Yet Determined: same fields as basic including fixtext (no advanced additions)', () => { - const rule = ref(makeRule({ status: 'Not Yet Determined' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) + describe("ruleFormFields - advanced mode", () => { + const advancedMode = ref(true); + + it("Configurable: includes advanced fields like version, rule_weight, fix_id, ident", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); + const d = ruleFormFields.value.displayed; + expect(d).toEqual( + expect.arrayContaining([ + "status", + "rule_severity", + "title", + "fixtext", + "vendor_comments", + "status_justification", + "version", + "rule_weight", + "artifact_description", + "fix_id", + "fixtext_fixref", + "ident", + "ident_system", + ]), + ); + }); + + it("Not Yet Determined: same fields as basic including fixtext (no advanced additions)", () => { + const rule = ref(makeRule({ status: "Not Yet Determined" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'title', 'fixtext']) - ) - }) + expect.arrayContaining(["status", "rule_severity", "title", "fixtext"]), + ); + }); - it('Inherently Meets: same as basic in advanced mode', () => { - const rule = ref(makeRule({ status: 'Applicable - Inherently Meets' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) + it("Inherently Meets: same as basic in advanced mode", () => { + const rule = ref(makeRule({ status: "Applicable - Inherently Meets" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'status_justification', 'artifact_description', 'vendor_comments']) - ) - }) - - it('Does Not Meet: same as basic in advanced mode', () => { - const rule = ref(makeRule({ status: 'Applicable - Does Not Meet' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) + expect.arrayContaining([ + "status", + "rule_severity", + "status_justification", + "artifact_description", + "vendor_comments", + ]), + ); + }); + + it("Does Not Meet: same as basic in advanced mode", () => { + const rule = ref(makeRule({ status: "Applicable - Does Not Meet" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'status_justification', 'vendor_comments']) - ) - }) - - it('Not Applicable: same as basic in advanced mode', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { ruleFormFields } = useRuleFormFields(rule, advancedMode) + expect.arrayContaining([ + "status", + "rule_severity", + "status_justification", + "vendor_comments", + ]), + ); + }); + + it("Not Applicable: same as basic in advanced mode", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { ruleFormFields } = useRuleFormFields(rule, advancedMode); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'status_justification', 'artifact_description', 'vendor_comments']) - ) - expect(ruleFormFields.value.disabled).toEqual(expect.arrayContaining(['rule_severity'])) - }) - }) + expect.arrayContaining([ + "status", + "rule_severity", + "status_justification", + "artifact_description", + "vendor_comments", + ]), + ); + expect(ruleFormFields.value.disabled).toEqual(expect.arrayContaining(["rule_severity"])); + }); + }); // ─── disaDescriptionFields: Basic mode ───────────────────── - describe('disaDescriptionFields - basic mode', () => { - const advancedMode = ref(false) + describe("disaDescriptionFields - basic mode", () => { + const advancedMode = ref(false); - it('Configurable: shows vuln_discussion', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode) + it("Configurable: shows vuln_discussion", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode); expect(disaDescriptionFields.value.displayed).toEqual( - expect.arrayContaining(['vuln_discussion']) - ) - }) + expect.arrayContaining(["vuln_discussion"]), + ); + }); - it('Not Yet Determined: shows vuln_discussion disabled', () => { - const rule = ref(makeRule({ status: 'Not Yet Determined' })) - const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode) + it("Not Yet Determined: shows vuln_discussion disabled", () => { + const rule = ref(makeRule({ status: "Not Yet Determined" })); + const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode); expect(disaDescriptionFields.value.displayed).toEqual( - expect.arrayContaining(['vuln_discussion']) - ) + expect.arrayContaining(["vuln_discussion"]), + ); expect(disaDescriptionFields.value.disabled).toEqual( - expect.arrayContaining(['vuln_discussion']) - ) - }) - - it('Inherently Meets: no DISA fields', () => { - const rule = ref(makeRule({ status: 'Applicable - Inherently Meets' })) - const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode) - expect(disaDescriptionFields.value.displayed).toEqual([]) - }) - - it('Does Not Meet: shows mitigations_available, mitigations, mitigation_control, poam_available, poam', () => { - const rule = ref(makeRule({ status: 'Applicable - Does Not Meet' })) - const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode) + expect.arrayContaining(["vuln_discussion"]), + ); + }); + + it("Inherently Meets: no DISA fields", () => { + const rule = ref(makeRule({ status: "Applicable - Inherently Meets" })); + const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode); + expect(disaDescriptionFields.value.displayed).toEqual([]); + }); + + it("Does Not Meet: shows mitigations_available, mitigations, mitigation_control, poam_available, poam", () => { + const rule = ref(makeRule({ status: "Applicable - Does Not Meet" })); + const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode); expect(disaDescriptionFields.value.displayed).toEqual( expect.arrayContaining([ - 'mitigations_available', 'mitigations', 'mitigation_control', - 'poam_available', 'poam', - ]) - ) - }) - - it('Not Applicable: no DISA fields', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode) - expect(disaDescriptionFields.value.displayed).toEqual([]) - }) - }) + "mitigations_available", + "mitigations", + "mitigation_control", + "poam_available", + "poam", + ]), + ); + }); + + it("Not Applicable: no DISA fields", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode); + expect(disaDescriptionFields.value.displayed).toEqual([]); + }); + }); // ─── disaDescriptionFields: Advanced mode ────────────────── - describe('disaDescriptionFields - advanced mode', () => { - const advancedMode = ref(true) + describe("disaDescriptionFields - advanced mode", () => { + const advancedMode = ref(true); - it('Configurable: shows all DISA description fields', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode) + it("Configurable: shows all DISA description fields", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode); expect(disaDescriptionFields.value.displayed).toEqual( expect.arrayContaining([ - 'vuln_discussion', 'documentable', 'false_positives', 'false_negatives', - 'mitigations_available', 'mitigations', 'poam_available', 'poam', - 'potential_impacts', 'third_party_tools', 'mitigation_control', - 'responsibility', 'ia_controls', - ]) - ) - }) - - it('Does Not Meet advanced: adds documentable, false_positives, etc.', () => { - const rule = ref(makeRule({ status: 'Applicable - Does Not Meet' })) - const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode) - const d = disaDescriptionFields.value.displayed - expect(d).toEqual(expect.arrayContaining([ - 'mitigations_available', 'mitigations', 'mitigation_control', - 'poam_available', 'poam', - 'documentable', 'false_positives', 'false_negatives', - 'potential_impacts', 'third_party_tools', - 'responsibility', 'ia_controls', - ])) - }) - - it('Inherently Meets: still no DISA fields even in advanced', () => { - const rule = ref(makeRule({ status: 'Applicable - Inherently Meets' })) - const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode) - expect(disaDescriptionFields.value.displayed).toEqual([]) - }) - - it('Not Applicable: still no DISA fields even in advanced', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode) - expect(disaDescriptionFields.value.displayed).toEqual([]) - }) - }) + "vuln_discussion", + "documentable", + "false_positives", + "false_negatives", + "mitigations_available", + "mitigations", + "poam_available", + "poam", + "potential_impacts", + "third_party_tools", + "mitigation_control", + "responsibility", + "ia_controls", + ]), + ); + }); + + it("Does Not Meet advanced: adds documentable, false_positives, etc.", () => { + const rule = ref(makeRule({ status: "Applicable - Does Not Meet" })); + const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode); + const d = disaDescriptionFields.value.displayed; + expect(d).toEqual( + expect.arrayContaining([ + "mitigations_available", + "mitigations", + "mitigation_control", + "poam_available", + "poam", + "documentable", + "false_positives", + "false_negatives", + "potential_impacts", + "third_party_tools", + "responsibility", + "ia_controls", + ]), + ); + }); + + it("Inherently Meets: still no DISA fields even in advanced", () => { + const rule = ref(makeRule({ status: "Applicable - Inherently Meets" })); + const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode); + expect(disaDescriptionFields.value.displayed).toEqual([]); + }); + + it("Not Applicable: still no DISA fields even in advanced", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { disaDescriptionFields } = useRuleFormFields(rule, advancedMode); + expect(disaDescriptionFields.value.displayed).toEqual([]); + }); + }); // ─── severity_override_guidance dynamic injection ────────── - describe('severity_override_guidance dynamic injection into rule fields', () => { - it('injects severity_override_guidance when severity changed + Configurable', () => { - const rule = ref(makeRule({ - status: 'Applicable - Configurable', - rule_severity: 'high', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.displayed).toContain('severity_override_guidance') - }) - - it('does NOT inject severity_override_guidance when severity matches', () => { - const rule = ref(makeRule({ - status: 'Applicable - Configurable', - rule_severity: 'medium', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.displayed).not.toContain('severity_override_guidance') - }) - - it('injects for Does Not Meet when severity changed', () => { - const rule = ref(makeRule({ - status: 'Applicable - Does Not Meet', - rule_severity: 'low', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.displayed).toContain('severity_override_guidance') - }) - - it('injects for Inherently Meets when severity changed', () => { - const rule = ref(makeRule({ - status: 'Applicable - Inherently Meets', - rule_severity: 'high', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.displayed).toContain('severity_override_guidance') - }) - - it('does NOT inject for Not Applicable', () => { - const rule = ref(makeRule({ - status: 'Not Applicable', - rule_severity: 'high', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.displayed).not.toContain('severity_override_guidance') - }) - - it('does NOT inject for Not Yet Determined', () => { - const rule = ref(makeRule({ - status: 'Not Yet Determined', - rule_severity: 'high', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.displayed).not.toContain('severity_override_guidance') - }) - - it('does not duplicate in advanced mode', () => { - const rule = ref(makeRule({ - status: 'Applicable - Configurable', - rule_severity: 'high', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { ruleFormFields } = useRuleFormFields(rule, ref(true)) - const count = ruleFormFields.value.displayed.filter(f => f === 'severity_override_guidance').length - expect(count).toBe(1) - }) - }) + describe("severity_override_guidance dynamic injection into rule fields", () => { + it("injects severity_override_guidance when severity changed + Configurable", () => { + const rule = ref( + makeRule({ + status: "Applicable - Configurable", + rule_severity: "high", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.displayed).toContain("severity_override_guidance"); + }); + + it("does NOT inject severity_override_guidance when severity matches", () => { + const rule = ref( + makeRule({ + status: "Applicable - Configurable", + rule_severity: "medium", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.displayed).not.toContain("severity_override_guidance"); + }); + + it("injects for Does Not Meet when severity changed", () => { + const rule = ref( + makeRule({ + status: "Applicable - Does Not Meet", + rule_severity: "low", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.displayed).toContain("severity_override_guidance"); + }); + + it("injects for Inherently Meets when severity changed", () => { + const rule = ref( + makeRule({ + status: "Applicable - Inherently Meets", + rule_severity: "high", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.displayed).toContain("severity_override_guidance"); + }); + + it("does NOT inject for Not Applicable", () => { + const rule = ref( + makeRule({ + status: "Not Applicable", + rule_severity: "high", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.displayed).not.toContain("severity_override_guidance"); + }); + + it("does NOT inject for Not Yet Determined", () => { + const rule = ref( + makeRule({ + status: "Not Yet Determined", + rule_severity: "high", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.displayed).not.toContain("severity_override_guidance"); + }); + + it("does not duplicate in advanced mode", () => { + const rule = ref( + makeRule({ + status: "Applicable - Configurable", + rule_severity: "high", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(true)); + const count = ruleFormFields.value.displayed.filter( + (f) => f === "severity_override_guidance", + ).length; + expect(count).toBe(1); + }); + }); // ─── checkFormFields ─────────────────────────────────────── - describe('checkFormFields', () => { - it('Configurable: shows content', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { checkFormFields } = useRuleFormFields(rule, ref(false)) - expect(checkFormFields.value.displayed).toEqual(['content']) - }) - - it('Not Yet Determined: shows check content disabled', () => { - const rule = ref(makeRule({ status: 'Not Yet Determined' })) - const { checkFormFields } = useRuleFormFields(rule, ref(false)) - expect(checkFormFields.value.displayed).toEqual(['content']) - expect(checkFormFields.value.disabled).toEqual(['content']) - }) - - it('Inherently Meets: no check fields', () => { - const rule = ref(makeRule({ status: 'Applicable - Inherently Meets' })) - const { checkFormFields } = useRuleFormFields(rule, ref(false)) - expect(checkFormFields.value.displayed).toEqual([]) - }) - - it('Does Not Meet: no check fields', () => { - const rule = ref(makeRule({ status: 'Applicable - Does Not Meet' })) - const { checkFormFields } = useRuleFormFields(rule, ref(false)) - expect(checkFormFields.value.displayed).toEqual([]) - }) - - it('Not Applicable: no check fields', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { checkFormFields } = useRuleFormFields(rule, ref(false)) - expect(checkFormFields.value.displayed).toEqual([]) - }) - - it('satisfied_by: shows content (effective status is Configurable)', () => { - const rule = ref(makeRule({ - status: 'Not Yet Determined', - satisfied_by: [{ id: 1 }], - })) - const { checkFormFields } = useRuleFormFields(rule, ref(false)) - expect(checkFormFields.value.displayed).toEqual(['content']) - }) - }) + describe("checkFormFields", () => { + it("Configurable: shows content", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { checkFormFields } = useRuleFormFields(rule, ref(false)); + expect(checkFormFields.value.displayed).toEqual(["content"]); + }); + + it("Not Yet Determined: shows check content disabled", () => { + const rule = ref(makeRule({ status: "Not Yet Determined" })); + const { checkFormFields } = useRuleFormFields(rule, ref(false)); + expect(checkFormFields.value.displayed).toEqual(["content"]); + expect(checkFormFields.value.disabled).toEqual(["content"]); + }); + + it("Inherently Meets: no check fields", () => { + const rule = ref(makeRule({ status: "Applicable - Inherently Meets" })); + const { checkFormFields } = useRuleFormFields(rule, ref(false)); + expect(checkFormFields.value.displayed).toEqual([]); + }); + + it("Does Not Meet: no check fields", () => { + const rule = ref(makeRule({ status: "Applicable - Does Not Meet" })); + const { checkFormFields } = useRuleFormFields(rule, ref(false)); + expect(checkFormFields.value.displayed).toEqual([]); + }); + + it("Not Applicable: no check fields", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { checkFormFields } = useRuleFormFields(rule, ref(false)); + expect(checkFormFields.value.displayed).toEqual([]); + }); + + it("satisfied_by: shows content (effective status is Configurable)", () => { + const rule = ref( + makeRule({ + status: "Not Yet Determined", + satisfied_by: [{ id: 1 }], + }), + ); + const { checkFormFields } = useRuleFormFields(rule, ref(false)); + expect(checkFormFields.value.displayed).toEqual(["content"]); + }); + }); // ─── satisfied_by behavior (R3) ──────────────────────────── - describe('satisfied_by behavior (R3)', () => { - it('uses Configurable field set when satisfied_by is set', () => { - const rule = ref(makeRule({ - status: 'Not Yet Determined', - satisfied_by: [{ id: 1, fixtext: 'parent fix' }], - })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) + describe("satisfied_by behavior (R3)", () => { + it("uses Configurable field set when satisfied_by is set", () => { + const rule = ref( + makeRule({ + status: "Not Yet Determined", + satisfied_by: [{ id: 1, fixtext: "parent fix" }], + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); expect(ruleFormFields.value.displayed).toEqual( - expect.arrayContaining(['status', 'rule_severity', 'title', 'fixtext', 'vendor_comments']) - ) - }) - - it('disables title and fixtext when satisfied_by is set', () => { - const rule = ref(makeRule({ - status: 'Not Yet Determined', - satisfied_by: [{ id: 1 }], - })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.disabled).toEqual( - expect.arrayContaining(['title', 'fixtext']) - ) - }) - - it('does NOT disable the entire form (isFormDisabled stays false)', () => { - const rule = ref(makeRule({ - satisfied_by: [{ id: 1 }], - })) - const { isFormDisabled } = useRuleFormFields(rule, ref(false)) - expect(isFormDisabled.value).toBe(false) - }) - }) + expect.arrayContaining(["status", "rule_severity", "title", "fixtext", "vendor_comments"]), + ); + }); + + it("disables title and fixtext when satisfied_by is set", () => { + const rule = ref( + makeRule({ + status: "Not Yet Determined", + satisfied_by: [{ id: 1 }], + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.disabled).toEqual(expect.arrayContaining(["title", "fixtext"])); + }); + + it("does NOT disable the entire form (isFormDisabled stays false)", () => { + const rule = ref( + makeRule({ + satisfied_by: [{ id: 1 }], + }), + ); + const { isFormDisabled } = useRuleFormFields(rule, ref(false)); + expect(isFormDisabled.value).toBe(false); + }); + }); // ─── section visibility helpers ──────────────────────────── - describe('section visibility', () => { - it('showDisaSection is true when DISA fields have displayed entries', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { showDisaSection } = useRuleFormFields(rule, ref(false)) - expect(showDisaSection.value).toBe(true) - }) - - it('showDisaSection is false when no DISA fields (Inherently Meets, basic)', () => { - const rule = ref(makeRule({ status: 'Applicable - Inherently Meets' })) - const { showDisaSection } = useRuleFormFields(rule, ref(false)) - expect(showDisaSection.value).toBe(false) - }) - - it('showDisaSection is false for Inherently Meets even when severity changed (override guidance now in rule fields)', () => { - const rule = ref(makeRule({ - status: 'Applicable - Inherently Meets', - rule_severity: 'high', - srg_rule_attributes: { rule_severity: 'medium' }, - })) - const { showDisaSection } = useRuleFormFields(rule, ref(false)) - expect(showDisaSection.value).toBe(false) - }) - - it('showChecksSection is true for Configurable', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { showChecksSection } = useRuleFormFields(rule, ref(false)) - expect(showChecksSection.value).toBe(true) - }) - - it('showChecksSection is true for Not Yet Determined (has disabled check content for context)', () => { - const rule = ref(makeRule({ status: 'Not Yet Determined' })) - const { showChecksSection } = useRuleFormFields(rule, ref(false)) - expect(showChecksSection.value).toBe(true) - }) - - it('showChecksSection is false for Not Applicable (no check fields)', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { showChecksSection } = useRuleFormFields(rule, ref(false)) - expect(showChecksSection.value).toBe(false) - }) - - it('showChecksSection is true when satisfied_by is set', () => { - const rule = ref(makeRule({ - status: 'Not Yet Determined', - satisfied_by: [{ id: 1 }], - })) - const { showChecksSection } = useRuleFormFields(rule, ref(false)) - expect(showChecksSection.value).toBe(true) - }) - }) + describe("section visibility", () => { + it("showDisaSection is true when DISA fields have displayed entries", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { showDisaSection } = useRuleFormFields(rule, ref(false)); + expect(showDisaSection.value).toBe(true); + }); + + it("showDisaSection is false when no DISA fields (Inherently Meets, basic)", () => { + const rule = ref(makeRule({ status: "Applicable - Inherently Meets" })); + const { showDisaSection } = useRuleFormFields(rule, ref(false)); + expect(showDisaSection.value).toBe(false); + }); + + it("showDisaSection is false for Inherently Meets even when severity changed (override guidance now in rule fields)", () => { + const rule = ref( + makeRule({ + status: "Applicable - Inherently Meets", + rule_severity: "high", + srg_rule_attributes: { rule_severity: "medium" }, + }), + ); + const { showDisaSection } = useRuleFormFields(rule, ref(false)); + expect(showDisaSection.value).toBe(false); + }); + + it("showChecksSection is true for Configurable", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { showChecksSection } = useRuleFormFields(rule, ref(false)); + expect(showChecksSection.value).toBe(true); + }); + + it("showChecksSection is true for Not Yet Determined (has disabled check content for context)", () => { + const rule = ref(makeRule({ status: "Not Yet Determined" })); + const { showChecksSection } = useRuleFormFields(rule, ref(false)); + expect(showChecksSection.value).toBe(true); + }); + + it("showChecksSection is false for Not Applicable (no check fields)", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { showChecksSection } = useRuleFormFields(rule, ref(false)); + expect(showChecksSection.value).toBe(false); + }); + + it("showChecksSection is true when satisfied_by is set", () => { + const rule = ref( + makeRule({ + status: "Not Yet Determined", + satisfied_by: [{ id: 1 }], + }), + ); + const { showChecksSection } = useRuleFormFields(rule, ref(false)); + expect(showChecksSection.value).toBe(true); + }); + }); // ─── showCollapsibleSections ───────────────────────────────── - describe('showCollapsibleSections', () => { - it('is true for Configurable (has advanced DISA additions)', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)) - expect(showCollapsibleSections.value).toBe(true) - }) - - it('is true for Does Not Meet (has advanced DISA additions)', () => { - const rule = ref(makeRule({ status: 'Applicable - Does Not Meet' })) - const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)) - expect(showCollapsibleSections.value).toBe(true) - }) - - it('is false for Not Yet Determined (no advanced additions)', () => { - const rule = ref(makeRule({ status: 'Not Yet Determined' })) - const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)) - expect(showCollapsibleSections.value).toBe(false) - }) - - it('is false for Not Applicable (no advanced additions)', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)) - expect(showCollapsibleSections.value).toBe(false) - }) - - it('is false for Inherently Meets (no advanced additions)', () => { - const rule = ref(makeRule({ status: 'Applicable - Inherently Meets' })) - const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)) - expect(showCollapsibleSections.value).toBe(false) - }) - }) + describe("showCollapsibleSections", () => { + it("is true for Configurable (has advanced DISA additions)", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)); + expect(showCollapsibleSections.value).toBe(true); + }); + + it("is true for Does Not Meet (has advanced DISA additions)", () => { + const rule = ref(makeRule({ status: "Applicable - Does Not Meet" })); + const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)); + expect(showCollapsibleSections.value).toBe(true); + }); + + it("is false for Not Yet Determined (no advanced additions)", () => { + const rule = ref(makeRule({ status: "Not Yet Determined" })); + const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)); + expect(showCollapsibleSections.value).toBe(false); + }); + + it("is false for Not Applicable (no advanced additions)", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)); + expect(showCollapsibleSections.value).toBe(false); + }); + + it("is false for Inherently Meets (no advanced additions)", () => { + const rule = ref(makeRule({ status: "Applicable - Inherently Meets" })); + const { showCollapsibleSections } = useRuleFormFields(rule, ref(true)); + expect(showCollapsibleSections.value).toBe(false); + }); + }); // ─── Exact field sets per status (strict assertions) ────── // These use toEqual (not arrayContaining) to catch accidental field additions/omissions. // Business rules source: docs/development/rule-form-business-rules.md - describe('exact field sets per status', () => { + describe("exact field sets per status", () => { const STATUSES = { - 'Applicable - Configurable': { + "Applicable - Configurable": { basic: { - rule: { displayed: ['status', 'rule_severity', 'title', 'fixtext', 'vendor_comments'], disabled: [] }, - disa: { displayed: ['vuln_discussion'], disabled: [] }, - check: { displayed: ['content'], disabled: [] }, + rule: { + displayed: ["status", "rule_severity", "title", "fixtext", "vendor_comments"], + disabled: [], + }, + disa: { displayed: ["vuln_discussion"], disabled: [] }, + check: { displayed: ["content"], disabled: [] }, }, advanced: { rule: { - displayed: ['status', 'rule_severity', 'title', 'fixtext', 'vendor_comments', - 'status_justification', 'version', 'rule_weight', 'artifact_description', - 'fix_id', 'fixtext_fixref', 'ident', 'ident_system'], + displayed: [ + "status", + "rule_severity", + "title", + "fixtext", + "vendor_comments", + "status_justification", + "version", + "rule_weight", + "artifact_description", + "fix_id", + "fixtext_fixref", + "ident", + "ident_system", + ], disabled: [], }, disa: { - displayed: ['vuln_discussion', 'documentable', 'false_positives', 'false_negatives', - 'mitigations_available', 'mitigations', 'poam_available', 'poam', - 'potential_impacts', 'third_party_tools', 'mitigation_control', - 'responsibility', 'ia_controls'], + displayed: [ + "vuln_discussion", + "documentable", + "false_positives", + "false_negatives", + "mitigations_available", + "mitigations", + "poam_available", + "poam", + "potential_impacts", + "third_party_tools", + "mitigation_control", + "responsibility", + "ia_controls", + ], disabled: [], }, - check: { displayed: ['content'], disabled: [] }, + check: { displayed: ["content"], disabled: [] }, }, }, - 'Not Yet Determined': { + "Not Yet Determined": { basic: { - rule: { displayed: ['status', 'rule_severity', 'title', 'fixtext'], disabled: ['title', 'rule_severity', 'fixtext'] }, - disa: { displayed: ['vuln_discussion'], disabled: ['vuln_discussion'] }, - check: { displayed: ['content'], disabled: ['content'] }, + rule: { + displayed: ["status", "rule_severity", "title", "fixtext"], + disabled: ["title", "rule_severity", "fixtext"], + }, + disa: { displayed: ["vuln_discussion"], disabled: ["vuln_discussion"] }, + check: { displayed: ["content"], disabled: ["content"] }, }, advanced: { - rule: { displayed: ['status', 'rule_severity', 'title', 'fixtext'], disabled: ['title', 'rule_severity', 'fixtext'] }, - disa: { displayed: ['vuln_discussion'], disabled: ['vuln_discussion'] }, - check: { displayed: ['content'], disabled: ['content'] }, + rule: { + displayed: ["status", "rule_severity", "title", "fixtext"], + disabled: ["title", "rule_severity", "fixtext"], + }, + disa: { displayed: ["vuln_discussion"], disabled: ["vuln_discussion"] }, + check: { displayed: ["content"], disabled: ["content"] }, }, }, - 'Applicable - Inherently Meets': { + "Applicable - Inherently Meets": { basic: { - rule: { displayed: ['status', 'rule_severity', 'status_justification', 'artifact_description', 'vendor_comments'], disabled: [] }, + rule: { + displayed: [ + "status", + "rule_severity", + "status_justification", + "artifact_description", + "vendor_comments", + ], + disabled: [], + }, disa: { displayed: [], disabled: [] }, check: { displayed: [], disabled: [] }, }, advanced: { - rule: { displayed: ['status', 'rule_severity', 'status_justification', 'artifact_description', 'vendor_comments'], disabled: [] }, + rule: { + displayed: [ + "status", + "rule_severity", + "status_justification", + "artifact_description", + "vendor_comments", + ], + disabled: [], + }, disa: { displayed: [], disabled: [] }, check: { displayed: [], disabled: [] }, }, }, - 'Applicable - Does Not Meet': { + "Applicable - Does Not Meet": { basic: { - rule: { displayed: ['status', 'rule_severity', 'status_justification', 'vendor_comments'], disabled: [] }, - disa: { displayed: ['mitigations_available', 'mitigations', 'mitigation_control', 'poam_available', 'poam'], disabled: [] }, + rule: { + displayed: ["status", "rule_severity", "status_justification", "vendor_comments"], + disabled: [], + }, + disa: { + displayed: [ + "mitigations_available", + "mitigations", + "mitigation_control", + "poam_available", + "poam", + ], + disabled: [], + }, check: { displayed: [], disabled: [] }, }, advanced: { - rule: { displayed: ['status', 'rule_severity', 'status_justification', 'vendor_comments'], disabled: [] }, + rule: { + displayed: ["status", "rule_severity", "status_justification", "vendor_comments"], + disabled: [], + }, disa: { - displayed: ['mitigations_available', 'mitigations', 'mitigation_control', 'poam_available', 'poam', - 'documentable', 'false_positives', 'false_negatives', 'potential_impacts', - 'third_party_tools', 'responsibility', 'ia_controls'], + displayed: [ + "mitigations_available", + "mitigations", + "mitigation_control", + "poam_available", + "poam", + "documentable", + "false_positives", + "false_negatives", + "potential_impacts", + "third_party_tools", + "responsibility", + "ia_controls", + ], disabled: [], }, check: { displayed: [], disabled: [] }, }, }, - 'Not Applicable': { + "Not Applicable": { basic: { - rule: { displayed: ['status', 'rule_severity', 'status_justification', 'artifact_description', 'vendor_comments'], disabled: ['rule_severity'] }, + rule: { + displayed: [ + "status", + "rule_severity", + "status_justification", + "artifact_description", + "vendor_comments", + ], + disabled: ["rule_severity"], + }, disa: { displayed: [], disabled: [] }, check: { displayed: [], disabled: [] }, }, advanced: { - rule: { displayed: ['status', 'rule_severity', 'status_justification', 'artifact_description', 'vendor_comments'], disabled: ['rule_severity'] }, + rule: { + displayed: [ + "status", + "rule_severity", + "status_justification", + "artifact_description", + "vendor_comments", + ], + disabled: ["rule_severity"], + }, disa: { displayed: [], disabled: [] }, check: { displayed: [], disabled: [] }, }, }, - } + }; for (const [status, modes] of Object.entries(STATUSES)) { describe(status, () => { for (const [mode, expected] of Object.entries(modes)) { - const isAdvanced = mode === 'advanced' + const isAdvanced = mode === "advanced"; it(`${mode} mode: exact ruleFormFields`, () => { - const rule = ref(makeRule({ status })) - const { ruleFormFields } = useRuleFormFields(rule, ref(isAdvanced)) - expect(ruleFormFields.value.displayed).toEqual(expected.rule.displayed) - expect(ruleFormFields.value.disabled).toEqual(expected.rule.disabled) - }) + const rule = ref(makeRule({ status })); + const { ruleFormFields } = useRuleFormFields(rule, ref(isAdvanced)); + expect(ruleFormFields.value.displayed).toEqual(expected.rule.displayed); + expect(ruleFormFields.value.disabled).toEqual(expected.rule.disabled); + }); it(`${mode} mode: exact disaDescriptionFields`, () => { - const rule = ref(makeRule({ status })) - const { disaDescriptionFields } = useRuleFormFields(rule, ref(isAdvanced)) - expect(disaDescriptionFields.value.displayed).toEqual(expected.disa.displayed) - expect(disaDescriptionFields.value.disabled).toEqual(expected.disa.disabled) - }) + const rule = ref(makeRule({ status })); + const { disaDescriptionFields } = useRuleFormFields(rule, ref(isAdvanced)); + expect(disaDescriptionFields.value.displayed).toEqual(expected.disa.displayed); + expect(disaDescriptionFields.value.disabled).toEqual(expected.disa.disabled); + }); it(`${mode} mode: exact checkFormFields`, () => { - const rule = ref(makeRule({ status })) - const { checkFormFields } = useRuleFormFields(rule, ref(isAdvanced)) - expect(checkFormFields.value.displayed).toEqual(expected.check.displayed) - expect(checkFormFields.value.disabled).toEqual(expected.check.disabled) - }) + const rule = ref(makeRule({ status })); + const { checkFormFields } = useRuleFormFields(rule, ref(isAdvanced)); + expect(checkFormFields.value.displayed).toEqual(expected.check.displayed); + expect(checkFormFields.value.disabled).toEqual(expected.check.disabled); + }); } - }) + }); } - }) - - // ─── Granular locking stubs (R4) ─────────────────────────── - describe('granular locking stubs (R4)', () => { - it('isFieldLocked returns false by default (no locked_fields)', () => { - const rule = ref(makeRule()) - const { isFieldLocked } = useRuleFormFields(rule, ref(false)) - expect(isFieldLocked('title')).toBe(false) - expect(isFieldLocked('fixtext')).toBe(false) - }) - - it('isFieldEditable returns true by default', () => { - const rule = ref(makeRule()) - const { isFieldEditable } = useRuleFormFields(rule, ref(false)) - expect(isFieldEditable('title')).toBe(true) - }) - - it('isFieldEditable returns false when form is disabled', () => { - const rule = ref(makeRule({ locked: true })) - const { isFieldEditable } = useRuleFormFields(rule, ref(false)) - expect(isFieldEditable('title')).toBe(false) - }) - }) + }); + + // ─── Per-section locking (R4) ─────────────────────────── + describe("per-section locking (R4)", () => { + it("isFieldLocked returns false by default (no locked_fields)", () => { + const rule = ref(makeRule()); + const { isFieldLocked } = useRuleFormFields(rule, ref(false)); + expect(isFieldLocked("title")).toBe(false); + expect(isFieldLocked("fixtext")).toBe(false); + }); + + it("isFieldLocked returns true when field section is locked", () => { + const rule = ref(makeRule({ locked_fields: { Title: true } })); + const { isFieldLocked } = useRuleFormFields(rule, ref(false)); + expect(isFieldLocked("title")).toBe(true); + }); + + it("isFieldLocked returns false for fields in unlocked sections", () => { + const rule = ref(makeRule({ locked_fields: { Title: true } })); + const { isFieldLocked } = useRuleFormFields(rule, ref(false)); + expect(isFieldLocked("fixtext")).toBe(false); + expect(isFieldLocked("status")).toBe(false); + }); + + it("isFieldLocked returns false when whole-rule is locked (whole-rule takes precedence)", () => { + const rule = ref(makeRule({ locked: true, locked_fields: { Title: true } })); + const { isFieldLocked } = useRuleFormFields(rule, ref(false)); + expect(isFieldLocked("title")).toBe(false); + }); + + it("isFieldEditable returns true by default", () => { + const rule = ref(makeRule()); + const { isFieldEditable } = useRuleFormFields(rule, ref(false)); + expect(isFieldEditable("title")).toBe(true); + }); + + it("isFieldEditable returns false when form is disabled", () => { + const rule = ref(makeRule({ locked: true })); + const { isFieldEditable } = useRuleFormFields(rule, ref(false)); + expect(isFieldEditable("title")).toBe(false); + }); + + it("isFieldEditable returns false when field section is locked", () => { + const rule = ref(makeRule({ locked_fields: { Title: true } })); + const { isFieldEditable } = useRuleFormFields(rule, ref(false)); + expect(isFieldEditable("title")).toBe(false); + }); + + it("locked sections inject fields into ruleFormFields disabled array", () => { + // Configurable shows status + title in basic mode + const rule = ref(makeRule({ locked_fields: { Title: true, Status: true } })); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.disabled).toContain("title"); + expect(ruleFormFields.value.disabled).toContain("status"); + }); + + it("locked sections inject status_justification when displayed", () => { + // Inherently Meets shows status_justification in basic mode + const rule = ref( + makeRule({ + status: "Applicable - Inherently Meets", + locked_fields: { Status: true }, + }), + ); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.disabled).toContain("status"); + expect(ruleFormFields.value.disabled).toContain("status_justification"); + }); + + it("locked sections inject fields into disaDescriptionFields disabled array", () => { + const rule = ref(makeRule({ locked_fields: { "Vulnerability Discussion": true } })); + const { disaDescriptionFields } = useRuleFormFields(rule, ref(true)); + expect(disaDescriptionFields.value.disabled).toContain("vuln_discussion"); + }); + + it("locked sections inject fields into checkFormFields disabled array", () => { + const rule = ref(makeRule({ locked_fields: { Check: true } })); + const { checkFormFields } = useRuleFormFields(rule, ref(false)); + expect(checkFormFields.value.disabled).toContain("content"); + }); + + it("does not inject locked fields that are not displayed", () => { + // Not Applicable status does not display fixtext + const rule = ref(makeRule({ status: "Not Applicable", locked_fields: { Fix: true } })); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.displayed).not.toContain("fixtext"); + // Should not add to disabled if not displayed + expect(ruleFormFields.value.disabled).not.toContain("fixtext"); + }); + + it("multiple sections can be locked simultaneously", () => { + const rule = ref( + makeRule({ + locked_fields: { Title: true, Fix: true, Check: true, "Vendor Comments": true }, + }), + ); + const { ruleFormFields, checkFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.disabled).toContain("title"); + expect(ruleFormFields.value.disabled).toContain("fixtext"); + expect(ruleFormFields.value.disabled).toContain("vendor_comments"); + expect(checkFormFields.value.disabled).toContain("content"); + }); + }); + + // ─── Field state visualization ─────────────────────────── + describe("fieldStateClass (visual state indicators)", () => { + it("returns empty string for normal editable fields", () => { + const rule = ref(makeRule()); + const { fieldStateClass } = useRuleFormFields(rule, ref(false)); + expect(fieldStateClass("title")).toBe(""); + }); + + it("returns section-locked class when field section is locked", () => { + const rule = ref(makeRule({ locked_fields: { Title: true } })); + const { fieldStateClass } = useRuleFormFields(rule, ref(false)); + expect(fieldStateClass("title")).toBe("field-state--section-locked"); + }); + + it("returns under-review class when rule is under review", () => { + const rule = ref(makeRule({ review_requestor_id: 42 })); + const { fieldStateClass } = useRuleFormFields(rule, ref(false)); + expect(fieldStateClass("title")).toBe("field-state--under-review"); + }); + + it("returns whole-locked class when rule is whole-locked", () => { + const rule = ref(makeRule({ locked: true })); + const { fieldStateClass } = useRuleFormFields(rule, ref(false)); + expect(fieldStateClass("title")).toBe("field-state--whole-locked"); + }); + + it("section-locked takes priority over under-review for locked fields", () => { + const rule = ref( + makeRule({ + locked_fields: { Title: true }, + review_requestor_id: 42, + }), + ); + const { fieldStateClass } = useRuleFormFields(rule, ref(false)); + // Section-locked field shows section-locked (higher priority) + expect(fieldStateClass("title")).toBe("field-state--section-locked"); + // Non-locked field shows under-review + expect(fieldStateClass("fixtext")).toBe("field-state--under-review"); + }); + + it("whole-locked overrides section-locked (section lock hidden)", () => { + const rule = ref(makeRule({ locked: true, locked_fields: { Title: true } })); + const { fieldStateClass } = useRuleFormFields(rule, ref(false)); + // isFieldLocked returns false when whole-locked, so whole-locked class applies + expect(fieldStateClass("title")).toBe("field-state--whole-locked"); + }); + + it("returns empty for unlocked sections when others are locked", () => { + const rule = ref(makeRule({ locked_fields: { Title: true } })); + const { fieldStateClass } = useRuleFormFields(rule, ref(false)); + expect(fieldStateClass("fixtext")).toBe(""); + }); + }); + + describe("activeFieldStates (legend display)", () => { + it("returns empty array for normal rule", () => { + const rule = ref(makeRule()); + const { activeFieldStates } = useRuleFormFields(rule, ref(false)); + expect(activeFieldStates.value).toEqual([]); + }); + + it("includes section-locked when sections are locked", () => { + const rule = ref(makeRule({ locked_fields: { Title: true } })); + const { activeFieldStates } = useRuleFormFields(rule, ref(false)); + expect(activeFieldStates.value).toContain("section-locked"); + }); + + it("includes under-review when rule is under review", () => { + const rule = ref(makeRule({ review_requestor_id: 42 })); + const { activeFieldStates } = useRuleFormFields(rule, ref(false)); + expect(activeFieldStates.value).toContain("under-review"); + }); + + it("includes whole-locked when rule is locked", () => { + const rule = ref(makeRule({ locked: true })); + const { activeFieldStates } = useRuleFormFields(rule, ref(false)); + expect(activeFieldStates.value).toContain("whole-locked"); + }); + + it("does not include section-locked when whole-locked", () => { + const rule = ref(makeRule({ locked: true, locked_fields: { Title: true } })); + const { activeFieldStates } = useRuleFormFields(rule, ref(false)); + expect(activeFieldStates.value).not.toContain("section-locked"); + expect(activeFieldStates.value).toContain("whole-locked"); + }); + }); // ─── severity disabled logic in ruleFormFields ───────────── - describe('severity disabled logic in ruleFormFields', () => { - it('rule_severity is NOT in disabled for Configurable (editable)', () => { - const rule = ref(makeRule({ status: 'Applicable - Configurable' })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.disabled).not.toContain('rule_severity') - }) - - it('rule_severity is NOT in disabled for Inherently Meets (now editable per R2)', () => { - const rule = ref(makeRule({ status: 'Applicable - Inherently Meets' })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.disabled).not.toContain('rule_severity') - }) - - it('rule_severity is NOT in disabled for Does Not Meet (now editable per R2)', () => { - const rule = ref(makeRule({ status: 'Applicable - Does Not Meet' })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.disabled).not.toContain('rule_severity') - }) - - it('rule_severity IS in disabled for Not Applicable', () => { - const rule = ref(makeRule({ status: 'Not Applicable' })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.disabled).toContain('rule_severity') - }) - - it('rule_severity IS in disabled for Not Yet Determined', () => { - const rule = ref(makeRule({ status: 'Not Yet Determined' })) - const { ruleFormFields } = useRuleFormFields(rule, ref(false)) - expect(ruleFormFields.value.disabled).toContain('rule_severity') - }) - }) -}) + describe("severity disabled logic in ruleFormFields", () => { + it("rule_severity is NOT in disabled for Configurable (editable)", () => { + const rule = ref(makeRule({ status: "Applicable - Configurable" })); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.disabled).not.toContain("rule_severity"); + }); + + it("rule_severity is NOT in disabled for Inherently Meets (now editable per R2)", () => { + const rule = ref(makeRule({ status: "Applicable - Inherently Meets" })); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.disabled).not.toContain("rule_severity"); + }); + + it("rule_severity is NOT in disabled for Does Not Meet (now editable per R2)", () => { + const rule = ref(makeRule({ status: "Applicable - Does Not Meet" })); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.disabled).not.toContain("rule_severity"); + }); + + it("rule_severity IS in disabled for Not Applicable", () => { + const rule = ref(makeRule({ status: "Not Applicable" })); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.disabled).toContain("rule_severity"); + }); + + it("rule_severity IS in disabled for Not Yet Determined", () => { + const rule = ref(makeRule({ status: "Not Yet Determined" })); + const { ruleFormFields } = useRuleFormFields(rule, ref(false)); + expect(ruleFormFields.value.disabled).toContain("rule_severity"); + }); + }); +}); diff --git a/spec/models/rule_section_locks_spec.rb b/spec/models/rule_section_locks_spec.rb new file mode 100644 index 000000000..6b3309baa --- /dev/null +++ b/spec/models/rule_section_locks_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Rule section locks' do + let_it_be(:shared_srg) do + srg_xml = Rails.root.join('db/seeds/srgs/U_GPOS_SRG_V3R3_Manual-xccdf.xml').read + parsed_benchmark = Xccdf::Benchmark.parse(srg_xml) + srg = SecurityRequirementsGuide.from_mapping(parsed_benchmark) + srg.xml = srg_xml + srg.save! + srg + end + let_it_be(:project) { Project.create(name: 'Section Lock Test') } + let_it_be(:component, refind: true) do + Component.create!(project: project, name: 'SL Test', title: 'SL', version: 'V1R1', + prefix: 'SLCK-01', based_on: shared_srg) + end + let_it_be(:rule, refind: true) do + Rule.create!(component: component, rule_id: 'SL-R1', + status: 'Applicable - Configurable', rule_severity: 'medium', + srg_rule: shared_srg.srg_rules.first) + end + + describe 'locked_fields column' do + it 'defaults to empty hash' do + expect(rule.locked_fields).to eq({}) + end + + it 'persists section lock state' do + rule.update!(locked_fields: { 'Title' => true, 'Check' => true }) + rule.reload + expect(rule.locked_fields).to eq({ 'Title' => true, 'Check' => true }) + end + + it 'appears in as_json output' do + rule.update!(locked_fields: { 'Status' => true }) + json = rule.as_json + expect(json['locked_fields']).to eq({ 'Status' => true }) + end + end + + describe 'locked_fields validation' do + it 'accepts valid section names' do + rule.locked_fields = { 'Title' => true, 'Check' => true, 'Fix' => true } + expect(rule).to be_valid + end + + it 'rejects invalid section names' do + rule.locked_fields = { 'InvalidSection' => true } + expect(rule).not_to be_valid + expect(rule.errors[:locked_fields]).to include(/invalid section/i) + end + + it 'accepts all valid LOCKABLE_SECTION_NAMES' do + all_sections = RuleConstants::LOCKABLE_SECTION_NAMES.index_with { true } + rule.locked_fields = all_sections + expect(rule).to be_valid + end + + it 'accepts empty hash' do + rule.locked_fields = {} + expect(rule).to be_valid + end + end + + describe 'amoeba duplication' do + it 'resets locked_fields on clone' do + rule.update!(locked_fields: { 'Title' => true, 'Status' => true }) + rule.update_single_rule_clone(true) + cloned = rule.amoeba_dup + expect(cloned.locked_fields).to eq({}) + end + end + + describe 'whole-rule lock interaction' do + it 'preserves section locks when whole-rule is locked' do + rule.update!(locked_fields: { 'Title' => true }) + # Whole-rule lock happens via Review system, just test data coexistence + expect(rule.locked_fields).to eq({ 'Title' => true }) + end + end +end diff --git a/spec/requests/rule_section_locks_spec.rb b/spec/requests/rule_section_locks_spec.rb new file mode 100644 index 000000000..8b7e91f71 --- /dev/null +++ b/spec/requests/rule_section_locks_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Rule section locks API' do + let_it_be(:admin_user) { create(:user, admin: true) } + let_it_be(:project) { create(:project) } + let_it_be(:component) { create(:component, project: project) } + + let(:rule) { component.rules.first } + + before do + Rails.application.reload_routes! + end + + # ========================================================================== + # REQUIREMENTS: + # 1. Admin and reviewer can lock/unlock sections + # 2. Authors cannot lock/unlock sections + # 3. Viewers cannot lock/unlock sections + # 4. Invalid section names are rejected + # 5. Locking a section persists to locked_fields jsonb + # 6. Unlocking removes section from locked_fields + # 7. Optional comment creates audit trail + # 8. Unauthenticated requests are rejected + # ========================================================================== + + describe 'PATCH /rules/:id/section_locks' do + context 'when unauthenticated' do + it 'redirects to login' do + patch "/rules/#{rule.id}/section_locks", params: { section: 'Title', locked: true } + expect(response).to redirect_to(new_user_session_path) + end + end + + context 'when admin' do + before do + sign_in admin_user + Membership.create!(user: admin_user, membership: project, role: 'admin') + end + + it 'locks a section' do + patch "/rules/#{rule.id}/section_locks", params: { section: 'Title', locked: true } + expect(response).to have_http_status(:ok) + rule.reload + expect(rule.locked_fields['Title']).to be true + end + + it 'unlocks a section' do + rule.update!(locked_fields: { 'Title' => true }) + patch "/rules/#{rule.id}/section_locks", params: { section: 'Title', locked: false } + expect(response).to have_http_status(:ok) + rule.reload + expect(rule.locked_fields).not_to have_key('Title') + end + + it 'rejects invalid section name' do + patch "/rules/#{rule.id}/section_locks", params: { section: 'Bogus', locked: true } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to match(/invalid section/i) + end + + it 'preserves other locked sections when toggling one' do + rule.update!(locked_fields: { 'Title' => true, 'Check' => true }) + patch "/rules/#{rule.id}/section_locks", params: { section: 'Title', locked: false } + expect(response).to have_http_status(:ok) + rule.reload + expect(rule.locked_fields).to eq({ 'Check' => true }) + end + + it 'returns updated rule JSON' do + patch "/rules/#{rule.id}/section_locks", params: { section: 'Status', locked: true } + body = response.parsed_body + expect(body['rule']['locked_fields']).to eq({ 'Status' => true }) + expect(body['toast']).to include('locked') + end + + it 'creates audit record with comment' do + expect do + patch "/rules/#{rule.id}/section_locks", + params: { section: 'Fix', locked: true, comment: 'Policy approved' } + end.to change { rule.audits.count }.by(1) + + audit = rule.audits.last + expect(audit.comment).to eq('Policy approved') + expect(audit.audited_changes).to have_key('locked_fields') + end + + it 'creates audit record with default comment when none provided' do + expect do + patch "/rules/#{rule.id}/section_locks", params: { section: 'Fix', locked: true } + end.to change { rule.audits.count }.by(1) + + audit = rule.audits.last + expect(audit.comment).to include('Locked section: Fix') + end + end + + context 'when reviewer' do + let_it_be(:reviewer) { create(:user) } + + before do + sign_in reviewer + Membership.create!(user: reviewer, membership: project, role: 'reviewer') + end + + it 'can lock a section' do + patch "/rules/#{rule.id}/section_locks", params: { section: 'Title', locked: true } + expect(response).to have_http_status(:ok) + end + + it 'can unlock a section' do + rule.update!(locked_fields: { 'Title' => true }) + patch "/rules/#{rule.id}/section_locks", params: { section: 'Title', locked: false } + expect(response).to have_http_status(:ok) + end + end + + context 'when author (insufficient permissions)' do + let_it_be(:author) { create(:user) } + + before do + sign_in author + Membership.create!(user: author, membership: project, role: 'author') + end + + it 'rejects the request' do + patch "/rules/#{rule.id}/section_locks", + params: { section: 'Title', locked: true }, + headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:internal_server_error) + end + end + + context 'when viewer (insufficient permissions)' do + let_it_be(:viewer) { create(:user) } + + before do + sign_in viewer + Membership.create!(user: viewer, membership: project, role: 'viewer') + end + + it 'rejects the request' do + patch "/rules/#{rule.id}/section_locks", + params: { section: 'Title', locked: true }, + headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:internal_server_error) + end + end + end + + describe 'PATCH /rules/:id/bulk_section_locks' do + before do + sign_in admin_user + Membership.create!(user: admin_user, membership: project, role: 'admin') + end + + it 'locks multiple sections at once' do + patch "/rules/#{rule.id}/bulk_section_locks", + params: { sections: %w[Title Status Check], locked: true } + expect(response).to have_http_status(:ok) + rule.reload + expect(rule.locked_fields).to eq({ 'Title' => true, 'Status' => true, 'Check' => true }) + end + + it 'unlocks multiple sections at once' do + rule.update!(locked_fields: { 'Title' => true, 'Status' => true, 'Check' => true }) + patch "/rules/#{rule.id}/bulk_section_locks", + params: { sections: %w[Title Check], locked: false } + expect(response).to have_http_status(:ok) + rule.reload + expect(rule.locked_fields).to eq({ 'Status' => true }) + end + + it 'rejects if any section name is invalid' do + patch "/rules/#{rule.id}/bulk_section_locks", + params: { sections: %w[Title Bogus], locked: true } + expect(response).to have_http_status(:unprocessable_entity) + end + end +end From d4ba308e91072f9d7fe1f9b8ee298b367072d1c0 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Fri, 20 Feb 2026 17:55:03 -0500 Subject: [PATCH 299/428] docs: section locks, authoring rules, deployment guide Add developer and user docs for per-section locking. Document mitigations/POA&M XOR toggle in business rules. Add deployment overview, production config guide, and troubleshooting page. Update VitePress nav and sidebar. Authored by: Aaron Lippold --- docs/.vitepress/config.js | 8 + docs/deployment/index.md | 46 ++++++ docs/development/rule-form-business-rules.md | 32 +++- docs/development/section-locks.md | 133 ++++++++++++++++ docs/getting-started/configuration.md | 55 +++++++ docs/getting-started/troubleshooting.md | 153 +++++++++++++++++++ docs/release-notes/index.md | 23 +++ docs/user-guide/authoring-rules.md | 106 +++++++++++++ docs/user-guide/section-locks.md | 81 ++++++++++ 9 files changed, 632 insertions(+), 5 deletions(-) create mode 100644 docs/deployment/index.md create mode 100644 docs/development/section-locks.md create mode 100644 docs/getting-started/troubleshooting.md create mode 100644 docs/release-notes/index.md create mode 100644 docs/user-guide/authoring-rules.md create mode 100644 docs/user-guide/section-locks.md diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index ae34457da..e00a6e5b2 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -35,7 +35,9 @@ export default defineConfig({ text: "User Guide", items: [ { text: "Overview", link: "/user-guide/overview" }, + { text: "Authoring Rules", link: "/user-guide/authoring-rules" }, { text: "User Management", link: "/user-guide/user-management" }, + { text: "Section Locks", link: "/user-guide/section-locks" }, { text: "Data Management", link: "/user-guide/data-management/" }, { text: "SAF Training", link: "https://mitre.github.io/saf-training/courses/guidance/" }, ], @@ -43,6 +45,7 @@ export default defineConfig({ { text: "Deployment", items: [ + { text: "Overview", link: "/deployment/" }, { text: "Docker", link: "/deployment/docker" }, { text: "Kubernetes", link: "/deployment/kubernetes" }, { text: "Heroku", link: "/deployment/heroku" }, @@ -128,6 +131,7 @@ export default defineConfig({ { text: "Installation", link: "/getting-started/installation" }, { text: "Configuration", link: "/getting-started/configuration" }, { text: "Environment Variables", link: "/getting-started/environment-variables" }, + { text: "Troubleshooting", link: "/getting-started/troubleshooting" }, ], }, ], @@ -136,7 +140,9 @@ export default defineConfig({ text: "User Guide", items: [ { text: "Overview", link: "/user-guide/overview" }, + { text: "Authoring Rules", link: "/user-guide/authoring-rules" }, { text: "User Management", link: "/user-guide/user-management" }, + { text: "Section Locks", link: "/user-guide/section-locks" }, ], }, { @@ -152,6 +158,7 @@ export default defineConfig({ { text: "Deployment Options", items: [ + { text: "Overview", link: "/deployment/" }, { text: "Docker", link: "/deployment/docker" }, { text: "Bare Metal", link: "/deployment/bare-metal" }, { text: "Heroku", link: "/deployment/heroku" }, @@ -186,6 +193,7 @@ export default defineConfig({ { text: "Documentation Guide", link: "/development/documentation" }, { text: "Architecture", link: "/development/architecture" }, { text: "Authorization", link: "/development/authorization" }, + { text: "Section Locks", link: "/development/section-locks" }, { text: "Testing", link: "/development/testing" }, { text: "Release Process", link: "/development/release-process" }, { text: "Vue 3 Migration", link: "/development/vue3-migration" }, diff --git a/docs/deployment/index.md b/docs/deployment/index.md new file mode 100644 index 000000000..5e4037608 --- /dev/null +++ b/docs/deployment/index.md @@ -0,0 +1,46 @@ +# Deployment Options + +Choose the deployment method that fits your infrastructure and team. + +## Decision Guide + +| Factor | [Docker](docker) | [Heroku](heroku) | [Kubernetes](kubernetes) | [Bare Metal](bare-metal) | +|--------|:-:|:-:|:-:|:-:| +| **Setup time** | Minutes | Minutes | Hours | Hours | +| **Ops overhead** | Low | Minimal | Medium | High | +| **Scaling** | Manual | Auto | Auto | Manual | +| **SSL/TLS** | Reverse proxy | Built-in | Ingress | Manual | +| **Cost** | Infrastructure | Platform fee | Infrastructure | Infrastructure | +| **Best for** | Small teams, on-prem | Quick start, prototyping | Large orgs, multi-tenant | Full control, air-gapped | + +## Quick Recommendations + +**Just trying Vulcan?** Start with [Docker](docker) — single command to run. + +**Production for a small team?** [Docker](docker) with a reverse proxy (nginx/traefik) for SSL. + +**Managed platform?** [Heroku](heroku) handles infrastructure, SSL, and backups. + +**Enterprise / multi-tenant?** [Kubernetes](kubernetes) with Helm chart for scaling and isolation. + +**Air-gapped / classified network?** [Bare Metal](bare-metal) for full control without container dependencies. + +## Common Requirements (All Deployments) + +Every production deployment needs: + +1. **Secrets** — `SECRET_KEY_BASE`, `CIPHER_PASSWORD`, `CIPHER_SALT` (generate with `openssl rand -hex 64`) +2. **PostgreSQL 12+** — dedicated database with secure password +3. **Authentication** — at least one provider (OIDC recommended, LDAP, or local login) +4. **SSL/TLS** — HTTPS for all production traffic + +See [Configuration](/getting-started/configuration) for the full "What You Must Provide" checklist. + +## Authentication Setup + +All deployment methods support the same authentication providers: + +- **[OIDC/Okta](auth/oidc-okta)** — recommended for production (Okta, Azure AD, Keycloak, Auth0) +- **[LDAP](auth/ldap)** — Active Directory / OpenLDAP integration +- **[GitHub OAuth](auth/github)** — lightweight OAuth for development teams +- **Local login** — email/password (enabled by default, disable for production) diff --git a/docs/development/rule-form-business-rules.md b/docs/development/rule-form-business-rules.md index bc8610892..27751821b 100644 --- a/docs/development/rule-form-business-rules.md +++ b/docs/development/rule-form-business-rules.md @@ -97,10 +97,10 @@ There are no technical means to achieve compliance. | Status Justification | Yes | Yes | | Vendor Comments | Yes | Yes | | Mitigations Available | Yes | Yes | -| Mitigations | Yes | Yes | -| Mitigation Control | Yes | Yes | -| POA&M Available | Yes | Yes | -| POA&M | Yes | Yes | +| Mitigations | When Mitigations Available ON | Yes | +| Mitigation Control | When Mitigations Available ON | Yes | +| POA&M Available | When Mitigations Available OFF | Yes | +| POA&M | When POA&M Available ON (and Mitigations OFF) | Yes | | IA Control | Yes | Read-only | | CCI | Yes | Read-only | @@ -159,7 +159,29 @@ These reference fields are **always visible** for all statuses and both modes. T ### Mitigations / POA&M Toggle Pattern -The `Mitigations Available` and `POA&M Available` fields are checkboxes that act as toggles. Their associated text fields (`Mitigations`, `POA&M`) only render when the parent checkbox is checked. +The `Mitigations Available` and `POA&M Available` fields are mutually exclusive (XOR) toggle switches with cascading visibility: + +**Mitigations Available toggle:** +- Always visible when in the displayed field list +- When ON: shows `Mitigations` textarea and `Mitigation Control` field +- When ON: hides `POA&M Available` toggle (and its dependent `POA&M` textarea) + +**POA&M Available toggle:** +- Only visible when `Mitigations Available` is OFF +- When ON: shows `POA&M` textarea + +**Conditional fields:** +| Field | Shows when | +|-------|-----------| +| Mitigations | `mitigations_available` is ON | +| Mitigation Control | `mitigations_available` is ON | +| POA&M Available toggle | `mitigations_available` is OFF | +| POA&M | `poam_available` is ON AND `mitigations_available` is OFF | + +**Always-visible fields** (not toggle-dependent): +Potential Impacts, Third Party Tools, Responsibility, IA Controls, Severity Override Guidance + +This XOR pattern ensures a rule has either a mitigation OR a POA&M, never both simultaneously. If data inconsistency occurs (both flags true), the mitigations path takes precedence and POA&M fields are hidden. ## Form-Level Disabled States diff --git a/docs/development/section-locks.md b/docs/development/section-locks.md new file mode 100644 index 000000000..e0cad191a --- /dev/null +++ b/docs/development/section-locks.md @@ -0,0 +1,133 @@ +# Per-Section Rule Locking — Developer Guide + +## Data Model + +### Database + +`base_rules.locked_fields` — `jsonb`, default `{}` + +Format: `{ "Title": true, "Check": true }`. Keys must be valid `LOCKABLE_SECTION_NAMES`. + +### Constants + +**Backend** (`app/constants/rule_constants.rb`): +```ruby +LOCKABLE_SECTION_NAMES = %w[ + Title Severity Status Fix Check + Vulnerability\ Discussion DISA\ Metadata + Vendor\ Comments Artifact\ Description XCCDF\ Metadata +].freeze +``` + +**Frontend** (`app/javascript/composables/ruleFieldConfig.js`): +```javascript +export const LOCKABLE_SECTIONS = { + Title: ["title"], + Severity: ["rule_severity", "severity_override_guidance"], + Status: ["status", "status_justification"], + Fix: ["fixtext", "fix_id", "fixtext_fixref"], + Check: ["content", "system", "content_ref_name", "content_ref_href"], + "Vulnerability Discussion": ["vuln_discussion"], + "DISA Metadata": ["documentable", "false_positives", ...], + "Vendor Comments": ["vendor_comments"], + "Artifact Description": ["artifact_description"], + "XCCDF Metadata": ["version", "rule_weight", "ident", "ident_system"], +}; + +// Reverse lookup — auto-resolves field → section (no manual wiring) +export const FIELD_TO_SECTION = Object.fromEntries( + Object.entries(LOCKABLE_SECTIONS).flatMap(([section, fields]) => + fields.map((field) => [field, section]), + ), +); +``` + +`RuleFormGroup` imports `FIELD_TO_SECTION` and auto-resolves the lock section for every field. No manual `lock-section` props needed anywhere. + +## API Endpoints + +### Per-Rule Section Lock + +``` +PATCH /rules/:id/section_locks +``` + +**Params**: `section` (string), `locked` (boolean), `comment` (optional string) + +**Authorization**: `can_review_component?` (admin or reviewer) + +**Response**: `{ rule: , toast: "Title locked" }` + +### Per-Rule Bulk Section Lock + +``` +PATCH /rules/:id/bulk_section_locks +``` + +**Params**: `sections` (array of strings), `locked` (boolean), `comment` (optional string) + +### Component-Level Bulk Section Lock + +``` +PATCH /components/:component_id/lock_sections +``` + +**Params**: `sections` (array), `locked` (boolean), `comment` (optional string) + +**Authorization**: `can_review_component?` + +Applies section locks to all unlocked rules in the component. + +## Frontend Architecture + +### Composable: `useRuleFormFields.js` + +- `isFieldLocked(fieldName)` — checks if a field's section is locked (returns false when whole-rule locked) +- `isFieldEditable(fieldName)` — combines form disabled + section lock checks +- `injectLockedFields(result)` — called in `ruleFormFields`, `disaDescriptionFields`, `checkFormFields` computeds to add locked fields to `disabled` arrays + +### Component Props Flow + +``` +RulesCodeEditorView + @toggle-section-lock → toggleSectionLock() → PATCH API → refresh:rule + ↓ +RuleEditor + @toggle-section-lock → relay up + ↓ +UnifiedRuleForm (computes lockedSections, canManageSectionLocks) + :locked-sections, :can-manage-section-locks, @toggle-section-lock + ↓ +RuleForm / DisaRuleDescriptionForm / CheckForm + Lock icons in labels, isSectionLocked(), toggleSectionLock() +``` + +### `canManageSectionLocks` Logic + +```javascript +if (readOnly || rule.locked || rule.review_requestor_id) return false; +return ["admin", "reviewer"].includes(effectivePermissions); +``` + +## Validation + +- `Rule#locked_fields_must_be_valid_sections` — rejects keys not in `LOCKABLE_SECTION_NAMES` +- `Rule` amoeba customize block resets `locked_fields` to `{}` on clone + +## Audit Trail + +Manual `Audited::Audit` records created via `rule.audits.create!()` (not via `audited` gem's auto-tracking, which excludes `locked_fields`). + +The `History.vue` component has a dedicated `computeLockedFieldsText()` method that shows "section locks updated (locked: Title, Status)" instead of raw JSON diffs. + +## Export/Import + +- **JSON Archive**: `locked_fields` included automatically via `rule.attributes` in `BackupSerializer`. Imported via `DIRECT_COLUMNS` in `RuleBuilder`. +- **XCCDF**: Not included (Vulcan-specific feature, not part of STIG schema). +- **CSV/XLSX**: Not included (workflow metadata, not content). May be added based on user feedback. + +## Testing + +- `spec/models/rule_section_locks_spec.rb` — 9 model tests +- `spec/requests/rule_section_locks_spec.rb` — 15 request tests +- `spec/javascript/composables/useRuleFormFields.spec.js` — 13 section lock tests (within 117 total) diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 733d0e161..4a18ec3d8 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -66,6 +66,61 @@ Each deployment type ships sensible defaults. Dev-friendly deployments enable lo | Bare metal production | `.env` (copy from `.env.production.example`) | Hardened: OIDC enabled, local login disabled | | No env vars at all | `vulcan.default.yml` + `0_settings.rb` | Defaults to dev-friendly (local login enabled) | +### What You Must Provide + +#### Development (local Rails or Docker quickstart) + +**Required** — nothing beyond what ships in `.env.example`. Copy it and go: + +```bash +cp .env.example .env +bundle exec rails db:prepare +foreman start -f Procfile.dev +``` + +Defaults give you: local login, registration, first-user-becomes-admin, no SMTP, no OIDC. The seed password is `1qaz!QAZ1qaz!QAZ`. + +#### Production Deployment + +**Required** (you must set these — no usable defaults): + +| Variable | Why | How to generate | +|----------|-----|-----------------| +| `SECRET_KEY_BASE` | Rails session encryption | `openssl rand -hex 64` | +| `CIPHER_PASSWORD` | Data encryption at rest | `openssl rand -hex 64` | +| `CIPHER_SALT` | Data encryption salt | `openssl rand -hex 64` | +| `POSTGRES_PASSWORD` | Database access | `openssl rand -hex 33` | +| `VULCAN_APP_URL` | Email links, OIDC callbacks | Your domain (e.g., `https://vulcan.example.com`) | + +**Required** — at least one auth provider: + +| Provider | Key Variables | +|----------|--------------| +| OIDC (recommended) | `VULCAN_ENABLE_OIDC=true`, `VULCAN_OIDC_ISSUER_URL`, `VULCAN_OIDC_CLIENT_ID`, `VULCAN_OIDC_CLIENT_SECRET` | +| LDAP | `VULCAN_ENABLE_LDAP=true`, `VULCAN_LDAP_HOST`, `VULCAN_LDAP_BASE`, `VULCAN_LDAP_BIND_DN`, `VULCAN_LDAP_ADMIN_PASS` | +| Local login | `VULCAN_ENABLE_LOCAL_LOGIN=true` (not recommended for production) | + +**Strongly recommended for production**: + +| Variable | Default | Production Value | Why | +|----------|---------|-----------------|-----| +| `VULCAN_ENABLE_LOCAL_LOGIN` | true | **false** | External auth is more secure | +| `VULCAN_ENABLE_USER_REGISTRATION` | true | **false** | Users provisioned via OIDC/LDAP | +| `VULCAN_FIRST_USER_ADMIN` | true | **false** | Use `VULCAN_ADMIN_EMAIL` instead | +| `VULCAN_SESSION_TIMEOUT` | 1h | **15m** | DoD standard (STIG AC-12) | +| `VULCAN_ENABLE_REMEMBER_ME` | true | **false** | Disable for high-security environments | +| `VULCAN_ENABLE_SMTP` | false | **true** | Enables email notifications and unlock | +| `RAILS_FORCE_SSL` | true | **true** | Keep default | + +**Optional** (sensible defaults work out of the box): + +- Account lockout — enabled by default, STIG AC-07 compliant +- Password policy — DoD 2222 defaults (15 chars, 2 of each type) +- Classification banner — disabled by default, set if required +- Consent modal — disabled by default, set if required + +Use `./setup-docker-secrets.sh` to generate all required secrets automatically, or copy `.env.production.example` and fill in your values. + ### Design Principles - **Opt-in services** (LDAP, OIDC, SMTP, Slack) default to `false` — they require external infrastructure diff --git a/docs/getting-started/troubleshooting.md b/docs/getting-started/troubleshooting.md new file mode 100644 index 000000000..1067b0ac1 --- /dev/null +++ b/docs/getting-started/troubleshooting.md @@ -0,0 +1,153 @@ +# Troubleshooting + +Common issues and solutions across all deployment types. + +## Database + +### Migration fails with "relation already exists" + +```bash +# Check migration status +bundle exec rails db:migrate:status + +# If stuck, verify schema version matches +bundle exec rails db:version +``` + +### "FATAL: role postgres does not exist" + +Create the PostgreSQL role: +```bash +createuser -s postgres +``` + +Or use your system username: +```bash +DATABASE_URL=postgres://$(whoami)@localhost/vulcan_vue_development +``` + +### Database connection refused + +Verify PostgreSQL is running: +```bash +# macOS +brew services list | grep postgresql + +# Linux +systemctl status postgresql +``` + +## Authentication + +### OIDC callback fails with "Invalid credentials" + +1. Verify `VULCAN_OIDC_REDIRECT_URI` matches exactly what's configured in your identity provider +2. Check the issuer URL responds: `curl -s https://your-domain/.well-known/openid-configuration` +3. Verify client ID and secret are correct (no trailing whitespace) + +### "Provider conflict" error on login + +A user with the same email already exists under a different auth provider. An admin must resolve this manually — Vulcan prevents silent account merging for security. + +### Locked out of admin account + +```bash +# Method 1: Rails console +bundle exec rails console +User.find_by(email: 'admin@example.com').unlock_access! + +# Method 2: Wait for auto-unlock (default 15 minutes) + +# Method 3: Create new admin +VULCAN_ADMIN_EMAIL=newadmin@example.com bundle exec rails db:prepare +``` + +## Assets / Frontend + +### "Module not found" or blank page after update + +```bash +yarn install +yarn build +# If using dev server: +foreman start -f Procfile.dev +``` + +### esbuild watch not picking up changes + +HAML template changes require a server restart. Vue component changes should be picked up by `yarn build:watch`. If not: + +```bash +# Kill any stale watch processes +pkill -f esbuild +yarn build:watch +``` + +## Docker + +### Container exits immediately + +Check logs: +```bash +docker compose logs web +``` + +Common causes: +- Missing `SECRET_KEY_BASE` — run `./setup-docker-secrets.sh` +- Database not ready — the entrypoint waits for PostgreSQL, but check `docker compose logs db` + +### "Permission denied" on mounted volumes + +```bash +# Fix ownership +docker compose run --rm web chown -R $(id -u):$(id -g) /app/storage +``` + +### Health check failing + +```bash +# Check health endpoint directly +curl -f http://localhost:3000/up + +# Check detailed health +curl http://localhost:3000/health_check +``` + +## Heroku + +### "Slug size too large" + +The `.slugignore` file excludes test files and dev configs. If still too large: +```bash +# Check what's taking space +heroku run du -sh */ --app your-app +``` + +### Review app database issues + +Review apps use `db:schema:load` (not `db:migrate`). If schema is out of date: +```bash +# Destroy and recreate the review app +heroku reviewapps:disable --app your-pipeline +heroku reviewapps:enable --app your-pipeline +``` + +## Performance + +### Slow page loads + +1. Check database query count: enable `config.log_level = :debug` and look for N+1 queries +2. Verify `RAILS_MAX_THREADS` and `WEB_CONCURRENCY` are set appropriately +3. For Docker: ensure jemalloc is enabled (default in production Dockerfile) + +### Rule editor feels sluggish + +Large components (500+ rules) can be slow. Workarounds: +- Use status/severity filters to reduce visible rules +- Use the search bar for quick navigation +- Close sidebars (History, Reviews) when not needed + +## Getting Help + +- **GitHub Issues**: [github.com/mitre/vulcan/issues](https://github.com/mitre/vulcan/issues) +- **SAF Community**: [saf.mitre.org](https://saf.mitre.org) diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md new file mode 100644 index 000000000..75b4f769f --- /dev/null +++ b/docs/release-notes/index.md @@ -0,0 +1,23 @@ +# Release Notes + +All Vulcan releases with changelogs and migration notes. + +## Current Release + +- **[v2.3.1](v2.3.1)** — Per-section rule locking, field state visualization, export modal UX, JSON archive backup/restore + +## Previous Releases + +- **[v2.2.1](v2.2.1)** — Account lockout (STIG AC-07), classification banner, consent modal, password policy, admin user management +- **[v2.2.0](v2.2.0)** — Rails 8 upgrade, request spec migration, MDI to Bootstrap icons migration + +## Upgrade Notes + +When upgrading between versions: + +1. **Read the release notes** for your target version +2. **Run database migrations**: `bundle exec rails db:migrate` +3. **Rebuild assets**: `yarn install && yarn build` +4. **Run tests**: `bundle exec parallel_rspec spec/ && yarn test:unit` + +For Docker deployments, pull the new image and restart. Migrations run automatically via `db:prepare` in the entrypoint. diff --git a/docs/user-guide/authoring-rules.md b/docs/user-guide/authoring-rules.md new file mode 100644 index 000000000..363ed78be --- /dev/null +++ b/docs/user-guide/authoring-rules.md @@ -0,0 +1,106 @@ +# Authoring Rules — Quick Reference + +This page provides a local reference for common authoring tasks. For in-depth training, see the [MITRE SAF Guidance Training](https://mitre.github.io/saf-training/courses/guidance/). + +## Workflow Overview + +``` +Create Project → Add Component (select SRG) → Author Rules → Review → Lock → Export +``` + +### 1. Create a Project + +Projects are containers for one or more Components. From the Projects page, click **New Project**. + +- **Name**: Organization or system name (e.g., "RHEL 9 STIG") +- **Visibility**: Controls who can see the project in search results + +### 2. Add a Component + +A Component is a STIG-in-progress. Each Component is based on an SRG (Security Requirements Guide). + +1. Open your project +2. Click **New Component** +3. Select the base SRG (e.g., "General Purpose Operating System V3R3") +4. Set a **prefix** (4 chars + hyphen + 2 chars, e.g., `RHEL-09`) + +The SRG's requirements become your rule set. + +### 3. Author Rules + +Each rule maps to an SRG requirement. Click a rule in the left navigator to edit it. + +#### Rule Statuses + +| Status | Meaning | Required Fields | +|--------|---------|----------------| +| Not Yet Determined | Default — not started | None (placeholder) | +| Applicable - Configurable | System can be configured to comply | Title, Fix, Check, Vuln Discussion | +| Applicable - Inherently Meets | System complies out of the box | Status Justification, Artifact Description | +| Applicable - Does Not Meet | No way to achieve compliance | Status Justification, Mitigations | +| Not Applicable | Requirement doesn't apply | Status Justification | + +#### Key Fields + +- **Title**: One-sentence vulnerability description +- **Fix Text**: How to remediate the vulnerability +- **Check Content**: How to verify the fix was applied +- **Vulnerability Discussion**: Detailed rationale for this control +- **Severity**: CAT I (High), CAT II (Medium), CAT III (Low) +- **Vendor Comments**: Notes for reviewers (not published in final STIG) + +### 4. Satisfactions + +When one rule's fix also satisfies another requirement: + +1. Open the satisfying rule +2. Click **Satisfies** in the toolbar +3. Search for and select the satisfied rule(s) + +Satisfied rules automatically inherit the satisfying rule's check and fix text. + +### 5. Review and Lock + +#### Per-Rule Review +1. Click **Request Review** in the toolbar +2. A reviewer examines the rule and approves or requests changes +3. Approved rules become locked (all fields read-only) + +#### Per-Section Lock +Reviewers and admins can lock individual sections (e.g., Status, Severity) while leaving other sections editable. Look for the lock/unlock icons next to section labels. + +#### Bulk Lock +From the component card, click **Lock** to lock all rules at once. Choose between: +- **Lock entire rules** — all fields locked +- **Lock sections only** — select which sections to lock + +### 6. Export + +From the component editor or project page, click **Export** and choose: + +| Mode | Purpose | +|------|---------| +| Working Copy | Internal review, sharing drafts | +| DISA Vendor Submission | Submit to DISA for review | +| STIG-Ready Publish Draft | Final publication-ready format | +| Backup | Full-fidelity JSON archive | + +Available formats depend on the mode: XCCDF, InSpec, CSV, Excel, JSON Archive. + +## Visual Field States + +When editing rules, colored left borders indicate field state: + +- **Yellow border** — section locked by a reviewer +- **Blue border** — rule is under review +- **Grey border** — entire rule is locked + +A badge above the form shows the overall lock status. A legend explains active states. + +## Tips + +- Use **Advanced Fields** toggle for metadata fields (rule weight, ident system, etc.) +- The **InSpec Control Body** tab lets you add custom InSpec test code +- **Related Rules** shows how other components implemented the same SRG requirement +- **History** sidebar tracks all changes with revert capability +- **Clone** duplicates a rule within the same component diff --git a/docs/user-guide/section-locks.md b/docs/user-guide/section-locks.md new file mode 100644 index 000000000..46b936039 --- /dev/null +++ b/docs/user-guide/section-locks.md @@ -0,0 +1,81 @@ +# Per-Section Rule Locking + +## Overview + +Vulcan supports two levels of rule locking: + +1. **Whole-rule lock** (via Review system) — locks the entire rule, preventing any edits. Requires admin permissions and a review comment. +2. **Per-section lock** — locks individual sections of a rule while leaving other sections editable. Available to admins and reviewers. + +Per-section locks enable the "book boss" workflow: a reviewer can lock policy fields (status, severity) after verification while leaving technical content (check text, fix text, vulnerability discussion) open for SME editing. + +## Lockable Sections + +| Section | Fields Locked | +|---------|--------------| +| Title | Title | +| Severity | Severity, Severity Override Guidance | +| Status | Status, Status Justification | +| Fix | Fix Text, Fix ID, Fix Text Reference | +| Check | Check Content, System, Reference Name, Reference Link | +| Vulnerability Discussion | Vulnerability Discussion | +| DISA Metadata | All DISA metadata fields (documentable, mitigations, etc.) | +| Vendor Comments | Vendor Comments | +| Artifact Description | Artifact Description | +| XCCDF Metadata | Version, Rule Weight, Identity, Identity System | + +## Using Section Locks + +### Per-Rule Section Locking + +When viewing a rule in the editor, admins and reviewers see lock/unlock icons next to each section label: + +- **Unlocked** (grey unlock icon) — section is editable. Click to lock. +- **Locked** (yellow lock icon) — section is read-only. Click to unlock. + +Lock icons are only visible when: +- You have admin or reviewer permissions +- The rule is not whole-rule locked +- The rule is not under review + +### Bulk Section Locking + +From the component card, click the **Lock** button. The modal now offers two modes: + +1. **Lock entire rules** — existing behavior, locks all fields on all unlocked rules +2. **Lock sections only** — select which sections to lock across all unlocked rules + +This lets you lock policy fields (e.g., Status + Severity) across an entire component while keeping technical sections editable. + +## Interaction with Whole-Rule Lock + +- When a rule is **whole-rule locked**, all fields are disabled regardless of section lock state. Section lock icons are hidden. +- When a rule is **unlocked** from whole-rule lock, any previously set section locks resume. +- Section locks are independent of the review workflow. + +## Audit Trail + +Section lock changes appear in the rule's history sidebar with: +- Which sections were locked/unlocked +- Who made the change +- Optional comment explaining the reason + +## Permissions + +| Action | Admin | Reviewer | Author | Viewer | +|--------|-------|----------|--------|--------| +| Lock/unlock sections | Yes | Yes | No | No | +| View locked section indicators | Yes | Yes | Yes | Yes | +| Edit locked section fields | No | No | No | No | + +## Cloning Rules + +When a rule is cloned (duplicated), section locks are **not** carried over. The cloned rule starts with no section locks. + +## Backup & Restore + +Section lock state is preserved in JSON archive backups and restored on import. XCCDF exports do not include section lock state (it's a Vulcan-specific editing feature, not part of STIG content). + +## Future Considerations + +CSV/XLSX import/export does not currently include section lock state. This is by design — section locks are workflow metadata, not STIG content. If user feedback indicates this would be useful (e.g., setting locks via spreadsheet), it can be added in a future release. From 086986d555aa4f57006373acc987a82cee8f40f3 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 21 Feb 2026 22:42:29 -0500 Subject: [PATCH 300/428] feat: authentication security hardening (PBKDF2, sessions, Devise audit) - PBKDF2-SHA512 password hashing with transparent bcrypt migration (FIPS 140-2) - Configurable concurrent session limits via mitre/devise-security fork (AC-10) - session_traceable module tracks per-user sessions with history table - Devise config audit: sign_out_via :delete, paranoid mode, cookie security, email/password change notifications, 2h reset token, lockable always-configured - Navbar sign out changed from GET link to DELETE form with CSRF token - ActiveRecord session store with httponly + same_site cookies Authored by: Aaron Lippold --- Gemfile | 8 + Gemfile.lock | 23 +++ app/javascript/components/navbar/App.vue | 23 ++- .../encryptable/encryptors/pbkdf2_sha512.rb | 38 +++++ app/models/user.rb | 25 ++- config/initializers/devise.rb | 38 +++-- config/initializers/session_store.rb | 9 + config/vulcan.default.yml | 3 + ...260221015937_add_password_salt_to_users.rb | 5 + .../20260221023915_add_sessions_table.rb | 12 ++ ...21023920_add_unique_session_id_to_users.rb | 5 + ...20260222010000_create_session_histories.rb | 20 +++ spec/models/user_auth_critical_tests_spec.rb | 5 +- .../user_authentication_edge_cases_spec.rb | 7 +- spec/models/user_pbkdf2_spec.rb | 86 ++++++++++ spec/requests/logout_confirmation_spec.rb | 49 ++++++ spec/requests/registrations_spec.rb | 8 +- spec/requests/session_invalidation_spec.rb | 32 ++++ spec/requests/session_limits_spec.rb | 158 ++++++++++++++++++ 19 files changed, 534 insertions(+), 20 deletions(-) create mode 100644 app/lib/devise/encryptable/encryptors/pbkdf2_sha512.rb create mode 100644 config/initializers/session_store.rb create mode 100644 db/migrate/20260221015937_add_password_salt_to_users.rb create mode 100644 db/migrate/20260221023915_add_sessions_table.rb create mode 100644 db/migrate/20260221023920_add_unique_session_id_to_users.rb create mode 100644 db/migrate/20260222010000_create_session_histories.rb create mode 100644 spec/models/user_pbkdf2_spec.rb create mode 100644 spec/requests/logout_confirmation_spec.rb create mode 100644 spec/requests/session_invalidation_spec.rb create mode 100644 spec/requests/session_limits_spec.rb diff --git a/Gemfile b/Gemfile index 6975fe746..c4dc22eb8 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,11 @@ gem 'jbuilder', '~> 2.7' gem 'haml-rails', '~> 2.0' # Add Devise for authentication gem 'devise', '~> 4.9' +# PBKDF2-SHA512 password hashing for FIPS 140-2 compliance +gem 'devise-encryptable' +# Session limiting (AC-10), session tracking, and server-side session store +gem 'devise-security', github: 'mitre/devise-security', branch: 'main' +gem 'activerecord-session_store' # Use Omniauth to support additional login providers gem 'omniauth', '~> 2.1' # LDAP Auth @@ -86,6 +91,9 @@ gem 'ox' gem 'rubyzip' +# Rate limiting and request throttling +gem 'rack-attack' + gem 'mitre-inspec-objects' gem 'rest-client' diff --git a/Gemfile.lock b/Gemfile.lock index 50753f12c..5bfa227b1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,11 @@ +GIT + remote: https://github.com/mitre/devise-security.git + revision: 9f560ed125c096205ba9434de8feea532ce97b4c + branch: main + specs: + devise-security (0.18.0) + devise (>= 4.8.1) + GEM remote: https://rubygems.org/ specs: @@ -56,6 +64,12 @@ GEM timeout (>= 0.4.0) activerecord-import (2.2.0) activerecord (>= 4.2) + activerecord-session_store (2.2.0) + actionpack (>= 7.0) + activerecord (>= 7.0) + cgi (>= 0.3.6) + rack (>= 2.0.8, < 4) + railties (>= 7.0) activestorage (8.0.2.1) actionpack (= 8.0.2.1) activejob (= 8.0.2.1) @@ -109,6 +123,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cgi (0.5.1) chef-config (18.8.11) addressable chef-utils (= 18.8.11) @@ -144,6 +159,8 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise-encryptable (0.2.0) + devise (>= 2.1.0) diff-lcs (1.6.2) docile (1.4.1) domain_name (0.6.20240107) @@ -424,6 +441,8 @@ GEM pyu-ruby-sasl (0.0.3.3) racc (1.8.1) rack (2.2.22) + rack-attack (6.8.0) + rack (>= 1.0, < 4) rack-oauth2 (1.21.3) activesupport attr_required @@ -684,6 +703,7 @@ PLATFORMS DEPENDENCIES abbrev activerecord-import + activerecord-session_store amoeba audited (~> 5.8.0) bootsnap (>= 1.4.2) @@ -694,6 +714,8 @@ DEPENDENCIES csv database_cleaner-active_record devise (~> 4.9) + devise-encryptable + devise-security! dotenv-rails factory_bot_rails (~> 6.5.0) fast_excel @@ -722,6 +744,7 @@ DEPENDENCIES pg_search propshaft puma (~> 7.0) + rack-attack rails (~> 8.0.0) rest-client rexml diff --git a/app/javascript/components/navbar/App.vue b/app/javascript/components/navbar/App.vue index 70ba4d7fb..fe93552c2 100644 --- a/app/javascript/components/navbar/App.vue +++ b/app/javascript/components/navbar/App.vue @@ -60,7 +60,7 @@ Profile Manage Users - Sign Out + Sign Out
    @@ -183,6 +183,27 @@ export default { this.latestRelease = ""; }); }, + signOut() { + const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content"); + const form = document.createElement("form"); + form.method = "POST"; + form.action = this.sign_out_path; + + const methodInput = document.createElement("input"); + methodInput.type = "hidden"; + methodInput.name = "_method"; + methodInput.value = "delete"; + form.appendChild(methodInput); + + const tokenInput = document.createElement("input"); + tokenInput.type = "hidden"; + tokenInput.name = "authenticity_token"; + tokenInput.value = csrfToken; + form.appendChild(tokenInput); + + document.body.appendChild(form); + form.submit(); + }, checkUpdateAvailable() { if (!this.latestRelease || this.latestRelease.trim() === "") return false; diff --git a/app/lib/devise/encryptable/encryptors/pbkdf2_sha512.rb b/app/lib/devise/encryptable/encryptors/pbkdf2_sha512.rb new file mode 100644 index 000000000..3d6d7aa65 --- /dev/null +++ b/app/lib/devise/encryptable/encryptors/pbkdf2_sha512.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'openssl' + +module Devise + module Encryptable + module Encryptors + # PBKDF2-SHA512 encryptor for FIPS 140-2 compliance. + # Uses OpenSSL::KDF.pbkdf2_hmac which delegates to the system's + # OpenSSL library. On FIPS-enabled systems (e.g., RHEL with + # fips-mode-setup --enable), this uses the FIPS-validated module. + # + # Password format: $pbkdf2-sha512$$$ + # This self-describing format enables future iteration upgrades. + class Pbkdf2Sha512 < Base + HASH_LENGTH = 64 # 512 bits + ALGORITHM = 'SHA512' + + def self.digest(password, stretches, salt, pepper) + combined_pepper = "#{password}#{pepper}" + hash = OpenSSL::KDF.pbkdf2_hmac( + combined_pepper, + salt: salt, + iterations: stretches, + length: HASH_LENGTH, + hash: ALGORITHM + ) + "$pbkdf2-sha512$#{stretches}$#{Base64.strict_encode64(salt)}$#{Base64.strict_encode64(hash)}" + end + + def self.compare(encrypted_password, password, stretches, salt, pepper) + new_hash = digest(password, stretches, salt, pepper) + ActiveSupport::SecurityUtils.secure_compare(encrypted_password, new_hash) + end + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 6a9ebed04..ea3b36898 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -12,7 +12,7 @@ class ProviderConflictError < StandardError; end # provider configuration. devise :database_authenticatable, :registerable, :rememberable, :recoverable, :confirmable, :trackable, :validatable, - :timeoutable, :lockable + :timeoutable, :lockable, :encryptable, :session_limitable, :session_traceable audited only: %i[admin name email], max_audits: 1000 @@ -23,6 +23,11 @@ class ProviderConflictError < StandardError; end validates :name, presence: true + # AC-10: Skip session limiting when disabled via settings + def skip_session_limitable? + !Settings.session_limits&.enabled + end + before_create :skip_confirmation!, unless: -> { Settings.local_login.email_confirmation } after_create :promote_first_user_to_admin @@ -34,6 +39,24 @@ class ProviderConflictError < StandardError; end scope :alphabetical, -> { order(:name) } + # Transparent password migration from bcrypt to PBKDF2-SHA512. + # On successful login with a bcrypt-hashed password, re-hashes with PBKDF2. + def valid_password?(password) + if encrypted_password.start_with?('$2a$', '$2b$') + # Legacy bcrypt password — verify with BCrypt directly + require 'bcrypt' + result = BCrypt::Password.new(encrypted_password) == password + if result + # Re-hash with PBKDF2-SHA512 + self.password = password + save(validate: false) + end + result + else + super + end + end + def available_projects admin ? Project.all : Project.where(id: projects.pluck(:id)).or(Project.discoverable).distinct end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index dd2058aab..bf581b58e 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -68,7 +68,7 @@ # It will change confirmation, password recovery and other workflows # to behave the same regardless if the e-mail provided was right or wrong. # Does not affect registerable. - # config.paranoid = true + config.paranoid = true # By default Devise will store the user in session. You can skip storage for # particular strategies by setting this option. @@ -101,10 +101,10 @@ config.stretches = Rails.env.test? ? 1 : 11 # Send a notification to the original email when the user's email is changed. - # config.send_email_changed_notification = false + config.send_email_changed_notification = true # Send a notification email when the user's password is changed. - # config.send_password_change_notification = false + config.send_password_change_notification = true # ==> Configuration for :confirmable # A period that the user is allowed to access the website even without @@ -152,7 +152,11 @@ # Options to be passed to the created cookie. For instance, you can set # secure: true in order to force SSL only cookies. - # config.rememberable_options = {} + config.rememberable_options = { + secure: Rails.env.production?, + httponly: true, + same_site: :lax + } # ==> Configuration for :validatable # Range for password length. @@ -174,9 +178,9 @@ # STIG AC-07: Lock after N consecutive invalid logon attempts. # Configured via VULCAN_LOCKOUT_* environment variables. # Defaults: 3 attempts, 15 min unlock, strategy: both (email + time). + config.lock_strategy = :failed_attempts + config.unlock_keys = [:email] if Settings.lockout&.enabled - config.lock_strategy = :failed_attempts - config.unlock_keys = [:email] # In test environment, use :time strategy to avoid SMTP dependency. # Devise sends unlock instructions email with :both/:email strategies, # which fails in CI where no SMTP server is available. @@ -184,8 +188,22 @@ config.maximum_attempts = Settings.lockout.maximum_attempts config.unlock_in = Settings.lockout.unlock_in_minutes.minutes config.last_attempt_warning = Settings.lockout.last_attempt_warning + else + # Effectively disable lockout while keeping the module loaded + config.maximum_attempts = 1_000_000 + config.unlock_strategy = :time + config.unlock_in = 1.minute end + # ==> Configuration for :session_limitable + :session_traceable (AC-10) + # Enforces per-user concurrent session limits via devise-security. + # session_traceable tracks individual sessions in the session_histories table. + # When max_active_sessions is exceeded, the oldest session is evicted. + # The :session_limitable module is conditionally skipped in the User model + # based on Settings.session_limits.enabled. + config.max_active_sessions = Settings.session_limits&.max_sessions || 1 + config.session_ip_verification = false # Behind reverse proxy, client IPs vary + # ==> Configuration for :recoverable # # Defines which key will be used when recovering the password for an account @@ -194,11 +212,11 @@ # Time interval you can reset your password with a reset password key. # Don't put a too small interval or your users won't have the time to # change their passwords. - config.reset_password_within = 6.hours + config.reset_password_within = 2.hours # When set to false, does not sign a user in automatically after their password is # reset. Defaults to true, so a user is signed in automatically after a reset. - # config.sign_in_after_reset_password = true + config.sign_in_after_reset_password = false # ==> Configuration for :encryptable # Allow you to use another hashing or encryption algorithm besides bcrypt (default). @@ -208,7 +226,7 @@ # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). # # Require the `devise-encryptable` gem when using anything other than bcrypt - # config.encryptor = :sha512 + config.encryptor = :pbkdf2_sha512 # ==> Scopes configuration # Turn scoped views on. Before rendering "sessions/new", it will first check for @@ -236,7 +254,7 @@ # config.navigational_formats = ['*/*', :html] # The default HTTP method used to sign out a resource. Default is :delete. - config.sign_out_via = :get + config.sign_out_via = :delete # ==> OmniAuth # Add a new OmniAuth provider. Check the wiki for more information on setting diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 000000000..677f1341e --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# AC-10: Server-side session store enables per-user session tracking. +# Cookie-based sessions can't be invalidated server-side; ActiveRecord store can. +Rails.application.config.session_store :active_record_store, + key: '_vulcan_session', + secure: Rails.env.production?, + httponly: true, + same_site: :lax diff --git a/config/vulcan.default.yml b/config/vulcan.default.yml index 6b0e205e8..5b58e79ea 100644 --- a/config/vulcan.default.yml +++ b/config/vulcan.default.yml @@ -95,6 +95,9 @@ defaults: &defaults version: <%= ENV.fetch('VULCAN_CONSENT_VERSION', '1') %> title: <%= ENV.fetch('VULCAN_CONSENT_TITLE', 'Terms of Use') %> content: <%= ENV['VULCAN_CONSENT_CONTENT'] || '' %> + session_limits: + enabled: <%= ENV.fetch('VULCAN_SESSION_LIMITS_ENABLED', 'true') != 'false' %> + max_sessions: <%= ENV.fetch('VULCAN_MAX_CONCURRENT_SESSIONS', '1').to_i %> lockout: enabled: <%= ENV.fetch('VULCAN_LOCKOUT_ENABLED', 'true') != 'false' %> maximum_attempts: <%= ENV.fetch('VULCAN_LOCKOUT_MAX_ATTEMPTS', '3').to_i %> diff --git a/db/migrate/20260221015937_add_password_salt_to_users.rb b/db/migrate/20260221015937_add_password_salt_to_users.rb new file mode 100644 index 000000000..6c1b58b41 --- /dev/null +++ b/db/migrate/20260221015937_add_password_salt_to_users.rb @@ -0,0 +1,5 @@ +class AddPasswordSaltToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :password_salt, :string + end +end diff --git a/db/migrate/20260221023915_add_sessions_table.rb b/db/migrate/20260221023915_add_sessions_table.rb new file mode 100644 index 000000000..aa61812ce --- /dev/null +++ b/db/migrate/20260221023915_add_sessions_table.rb @@ -0,0 +1,12 @@ +class AddSessionsTable < ActiveRecord::Migration[8.0] + def change + create_table :sessions do |t| + t.string :session_id, :null => false + t.text :data + t.timestamps + end + + add_index :sessions, :session_id, :unique => true + add_index :sessions, :updated_at + end +end diff --git a/db/migrate/20260221023920_add_unique_session_id_to_users.rb b/db/migrate/20260221023920_add_unique_session_id_to_users.rb new file mode 100644 index 000000000..61215e79c --- /dev/null +++ b/db/migrate/20260221023920_add_unique_session_id_to_users.rb @@ -0,0 +1,5 @@ +class AddUniqueSessionIdToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :unique_session_id, :string + end +end diff --git a/db/migrate/20260222010000_create_session_histories.rb b/db/migrate/20260222010000_create_session_histories.rb new file mode 100644 index 000000000..25291e8a9 --- /dev/null +++ b/db/migrate/20260222010000_create_session_histories.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateSessionHistories < ActiveRecord::Migration[8.0] + def up + create_table :session_histories do |t| + t.string :token, null: false, index: { unique: true } + t.inet :ip_address, index: true + t.string :user_agent + t.datetime :last_accessed_at, null: false, index: true + t.boolean :active, default: true, null: false, index: true + t.belongs_to :owner, polymorphic: true, null: false, index: true + + t.timestamps null: false + end + end + + def down + drop_table :session_histories + end +end diff --git a/spec/models/user_auth_critical_tests_spec.rb b/spec/models/user_auth_critical_tests_spec.rb index 354ee78fe..af0012d19 100644 --- a/spec/models/user_auth_critical_tests_spec.rb +++ b/spec/models/user_auth_critical_tests_spec.rb @@ -152,8 +152,9 @@ it 'uses full-length Devise token for new users' do auth = mock_omniauth_response(build(:user, email: 'new@example.com'), provider: 'oidc') - # Mock to verify full token is used - expect(Devise).to receive(:friendly_token).with(no_args).and_call_original + # Verify Devise.friendly_token is called (at least once for password, + # devise-encryptable also calls it for password_salt generation) + expect(Devise).to receive(:friendly_token).with(no_args).at_least(:once).and_call_original user = User.from_omniauth(auth) expect(user.password).to be_present diff --git a/spec/models/user_authentication_edge_cases_spec.rb b/spec/models/user_authentication_edge_cases_spec.rb index f77911f3c..0eecc7a5d 100644 --- a/spec/models/user_authentication_edge_cases_spec.rb +++ b/spec/models/user_authentication_edge_cases_spec.rb @@ -123,10 +123,13 @@ it 'uses full-length Devise token for new users' do auth = base_auth - expect(Devise).to receive(:friendly_token).with(no_args).and_return('full_length_secure_token') + # devise-encryptable also calls friendly_token for password_salt, + # so we allow multiple calls but verify the password was set + expect(Devise).to receive(:friendly_token).with(no_args).at_least(:once).and_call_original user = User.from_omniauth(auth) - expect(user.password).to eq('full_length_secure_token') + expect(user.password).to be_present + expect(user.password.length).to be >= 20 end it 'does not change password for existing users' do diff --git a/spec/models/user_pbkdf2_spec.rb b/spec/models/user_pbkdf2_spec.rb new file mode 100644 index 000000000..a307e29b4 --- /dev/null +++ b/spec/models/user_pbkdf2_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'bcrypt' + +RSpec.describe 'PBKDF2-SHA512 password hashing' do + before do + Rails.application.reload_routes! + end + + let(:password) { 'S3cure!#Pass001' } + + describe 'Pbkdf2Sha512 encryptor' do + let(:encryptor) { Devise::Encryptable::Encryptors::Pbkdf2Sha512 } + let(:salt) { SecureRandom.random_bytes(32) } + let(:stretches) { 1 } + let(:pepper) { Devise.pepper } + + it 'produces a self-describing hash format' do + hash = encryptor.digest(password, stretches, salt, pepper) + expect(hash).to start_with('$pbkdf2-sha512$') + end + + it 'verifies correct passwords' do + hash = encryptor.digest(password, stretches, salt, pepper) + expect(encryptor.compare(hash, password, stretches, salt, pepper)).to be true + end + + it 'rejects incorrect passwords' do + hash = encryptor.digest(password, stretches, salt, pepper) + expect(encryptor.compare(hash, 'WrongPassword!1', stretches, salt, pepper)).to be false + end + + it 'produces different hashes for different passwords' do + hash1 = encryptor.digest(password, stretches, salt, pepper) + hash2 = encryptor.digest('OtherP@ss12345!', stretches, salt, pepper) + expect(hash1).not_to eq(hash2) + end + end + + describe 'User authentication with PBKDF2' do + let(:user) { create(:user, password: password) } + + it 'authenticates with correct password' do + expect(user.valid_password?(password)).to be true + end + + it 'rejects incorrect password' do + expect(user.valid_password?('WrongPassword!1')).to be false + end + + it 'stores password in PBKDF2 format for new users' do + expect(user.encrypted_password).to start_with('$pbkdf2-sha512$') + end + end + + describe 'bcrypt to PBKDF2 migration' do + let(:user) { create(:user, password: password) } + + it 'migrates bcrypt passwords on successful login' do + # Manually set a bcrypt password to simulate pre-migration state + bcrypt_hash = BCrypt::Password.create(password, cost: 4) + user.update_column(:encrypted_password, bcrypt_hash) + user.reload + + expect(user.encrypted_password).to start_with('$2a$') + + # Login should succeed and migrate the password + expect(user.valid_password?(password)).to be true + + user.reload + expect(user.encrypted_password).to start_with('$pbkdf2-sha512$') + end + + it 'does not migrate on failed login' do + bcrypt_hash = BCrypt::Password.create(password, cost: 4) + user.update_column(:encrypted_password, bcrypt_hash) + user.reload + + user.valid_password?('WrongPassword!1') + + user.reload + expect(user.encrypted_password).to start_with('$2a$') + end + end +end diff --git a/spec/requests/logout_confirmation_spec.rb b/spec/requests/logout_confirmation_spec.rb new file mode 100644 index 000000000..9ec8a4aca --- /dev/null +++ b/spec/requests/logout_confirmation_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENT (AC-12(02) / V-222392): The application must display an explicit +# logoff message to users indicating the reliable termination of authenticated +# communications sessions. +# +# Devise sets flash[:notice] to I18n.t('devise.sessions.signed_out') on logout. +# The Toaster Vue component (present on every page via application layout) +# receives flash notices as props and displays them client-side. +RSpec.describe 'Logout confirmation message' do + before do + Rails.application.reload_routes! + end + + it 'sets a signed-out flash notice after logout' do + user = create(:user) + sign_in user + + # Verify we're logged in + get '/projects' + expect(response).to have_http_status(:success) + + # Log out (Devise uses DELETE for sign_out per best practices) + delete destroy_user_session_path + expect(response).to redirect_to(root_path) + + # Verify Devise sets the flash notice for the Toaster to display + expect(flash[:notice]).to eq(I18n.t('devise.sessions.signed_out')) + + # Verify the flash is passed to the Toaster component as a prop in the layout + follow_redirect! # root -> login (unauthenticated redirect) + follow_redirect! # -> login page rendered + # The layout passes notice as a Vue prop: 'v-bind:notice': notice.to_json + expect(response.body).to include('v-bind:notice') + end + + it 'invalidates the session so subsequent requests require login' do + user = create(:user) + sign_in user + + delete destroy_user_session_path + + # After logout, accessing a protected page should redirect to login + get '/projects' + expect(response).to redirect_to(new_user_session_path) + end +end diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb index fdfd61590..5484797ec 100644 --- a/spec/requests/registrations_spec.rb +++ b/spec/requests/registrations_spec.rb @@ -174,11 +174,11 @@ } expect(response).to have_http_status(:redirect) - # Check that confirmation email was sent for email change - expect(ActionMailer::Base.deliveries.count).to eq(1) - confirmation_email = ActionMailer::Base.deliveries.last + # Devise sends: confirmation email + email-changed notification + password-changed notification + expect(ActionMailer::Base.deliveries.count).to eq(3) + confirmation_email = ActionMailer::Base.deliveries.find { |e| e.subject.include?('Confirmation') } + expect(confirmation_email).to be_present expect(confirmation_email.to).to include(new_user_data.email) - expect(confirmation_email.subject).to include('Confirmation') existing_user.reload.confirm diff --git a/spec/requests/session_invalidation_spec.rb b/spec/requests/session_invalidation_spec.rb new file mode 100644 index 000000000..d1f4e0c6c --- /dev/null +++ b/spec/requests/session_invalidation_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENT (Finding 7): When a user is deleted from the database, +# their active session must be terminated. The next authenticated +# request should redirect to the login page instead of succeeding. +# +# Devise cookie-based sessions store the user ID. When the user record +# no longer exists, Devise's Warden strategy fails to deserialize +# the session, effectively invalidating it. +RSpec.describe 'Session invalidation on user deletion' do + before do + Rails.application.reload_routes! + end + + it 'redirects to login after the user is deleted' do + user = create(:user) + sign_in user + + # Confirm authenticated access works before deletion + get '/projects' + expect(response).to have_http_status(:success) + + # Delete the user from the database + user.destroy! + + # Next request should fail authentication and redirect to login + get '/projects' + expect(response).to redirect_to(new_user_session_path) + end +end diff --git a/spec/requests/session_limits_spec.rb b/spec/requests/session_limits_spec.rb new file mode 100644 index 000000000..15aa8a608 --- /dev/null +++ b/spec/requests/session_limits_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +# REQUIREMENTS: +# AC-10 (V-222387): The application must limit the number of logon sessions per user. +# - When session limits enabled, concurrent sessions are enforced. +# - max_sessions configures how many concurrent sessions are allowed (default: 1). +# - When max exceeded, the oldest session is evicted (not rejected). +# - Session history records are created per login, updated per request, expired on logout. +# - When session_traceable is active, unique_traceable_token replaces unique_session_id. + +require 'rails_helper' + +RSpec.describe 'Session Limits (AC-10)' do + before do + Rails.application.reload_routes! + end + + let(:password) { 'S3cure!#Pass001' } + let(:user) { create(:user, password: password, password_confirmation: password) } + + describe 'session history tracking' do + it 'creates a session_history record on login' do + expect { login(user) }.to change(SessionHistory, :count).by(1) + end + + it 'records IP address and user agent' do + login(user) + history = SessionHistory.last + + expect(history.ip_address).to be_present + expect(history.user_agent).to be_present + expect(history.active).to be true + expect(history.last_accessed_at).to be_present + end + + it 'expires session history on logout' do + login(user) + follow_redirect! # follow the redirect to establish the session fully + history = SessionHistory.last + expect(history.active).to be true + + delete destroy_user_session_path + history.reload + + expect(history.active).to be false + end + end + + describe 'concurrent session enforcement (max_sessions: 1)' do + it 'invalidates the first session when user logs in a second time' do + # First login + first_cookies = login(user) + + # Second login (new session) + reset! + login(user) + + # First session should be evicted + reset! + restore_cookies(first_cookies) + get projects_path + expect(response).to redirect_to(new_user_session_path) + end + + it 'allows the latest session to remain active' do + login(user) # first + reset! + login(user) # second (evicts first) + follow_redirect! + + get projects_path + expect(response).to have_http_status(:ok).or redirect_to(root_path) + end + + it 'creates session_history records for each login' do + login(user) + reset! + login(user) + + expect(user.session_histories.count).to eq(2) + # Only the latest should be active (oldest evicted during second login) + expect(user.session_histories.where(active: true).count).to eq(1) + end + end + + describe 'configurable max_sessions' do + around do |example| + original = Devise.max_active_sessions + Devise.max_active_sessions = 2 + example.run + ensure + Devise.max_active_sessions = original + end + + it 'allows 2 concurrent sessions when max_sessions is 2' do + first_cookies = login(user) + + reset! + login(user) + + # Both sessions should work + reset! + restore_cookies(first_cookies) + get projects_path + expect(response).not_to redirect_to(new_user_session_path) + end + + it 'evicts the oldest when a 3rd session exceeds max of 2' do + first_cookies = login(user) + + reset! + login(user) # second + + reset! + login(user) # third — should evict first + + # First session should be evicted + reset! + restore_cookies(first_cookies) + get projects_path + expect(response).to redirect_to(new_user_session_path) + end + end + + describe 'unique_session_id backward compatibility' do + it 'unique_session_id column still updated on login' do + expect(user.unique_session_id).to be_nil + + login(user) + user.reload + + # With session_traceable, unique_session_id may still be set by session_limitable + # OR the traceable token is used instead — either way, session is tracked + expect(user.session_histories.count).to eq(1) + end + end + + private + + # Login and return the cookie string for later replay + def login(user_record) + post user_session_path, + params: { user: { email: user_record.email, password: password } }, + headers: { 'User-Agent' => 'RSpec Test Browser' } + expect(response).to redirect_to(root_path) + response.headers['Set-Cookie'] + end + + # Parse a Set-Cookie header string into the test cookie jar + def restore_cookies(cookie_header) + return unless cookie_header + + cookie_header.split("\n").each do |line| + name, value = line.split(';').first.split('=', 2) + cookies[name.strip] = value&.strip + end + end +end From dc05b9efb4fd0ad945b2a963f71054fcab379166 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 21 Feb 2026 22:42:42 -0500 Subject: [PATCH 301/428] fix: input security hardening (XXE, uploads, rate limiting) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - XXE prevention: NOENT→NONET in Nokogiri, HappyMapper NONET patch - Upload validation concern: file size + content-type checks on all endpoints - Rate limiting via rack-attack: login throttling, upload throttling - Input length limits on Project/Component metadata fields Authored by: Aaron Lippold --- app/controllers/components_controller.rb | 9 ++ .../concerns/upload_validatable.rb | 52 +++++++ app/controllers/projects_controller.rb | 3 + ...security_requirements_guides_controller.rb | 3 + app/controllers/stigs_controller.rb | 3 + app/models/component.rb | 4 + app/models/disa_rule_description.rb | 2 +- app/models/project.rb | 3 +- config/initializers/nokogiri_security.rb | 24 +++ config/initializers/rack_attack.rb | 45 ++++++ spec/models/disa_rule_description_spec.rb | 56 +++++++ spec/models/input_length_limits_spec.rb | 21 +++ spec/rails_helper.rb | 2 + spec/requests/rack_attack_spec.rb | 50 +++++++ spec/requests/upload_validation_spec.rb | 140 ++++++++++++++++++ 15 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 app/controllers/concerns/upload_validatable.rb create mode 100644 config/initializers/nokogiri_security.rb create mode 100644 config/initializers/rack_attack.rb create mode 100644 spec/models/disa_rule_description_spec.rb create mode 100644 spec/models/input_length_limits_spec.rb create mode 100644 spec/requests/rack_attack_spec.rb create mode 100644 spec/requests/upload_validation_spec.rb diff --git a/app/controllers/components_controller.rb b/app/controllers/components_controller.rb index bad2736fa..aae6043d6 100644 --- a/app/controllers/components_controller.rb +++ b/app/controllers/components_controller.rb @@ -5,6 +5,7 @@ # class ComponentsController < ApplicationController include Exportable + include UploadValidatable EXPORT_ERROR_TITLE = 'Export error' CONTROL_NOT_FOUND_TITLE = 'Control not found' @@ -23,6 +24,7 @@ class ComponentsController < ApplicationController before_action :authorize_logged_in, only: %i[search index based_on_same_srg bulk_export] before_action :authorize_compare_access, only: %i[compare] before_action :authorize_viewer_project, only: %i[history] + before_action :validate_component_upload, only: :create def index components = Component.with_severity_counts @@ -501,6 +503,13 @@ def component_update_params ) end + def validate_component_upload + file = params.dig(:component, :file) + return if file.blank? # no file = creating from SRG, not spreadsheet import + + validate_upload_size(file, 50.megabytes) && validate_upload_type(file, %w[.xlsx .csv]) + end + def component_create_params params.expect( component: [:id, diff --git a/app/controllers/concerns/upload_validatable.rb b/app/controllers/concerns/upload_validatable.rb new file mode 100644 index 000000000..52babeaaa --- /dev/null +++ b/app/controllers/concerns/upload_validatable.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Shared upload validation for file size and content type. +# Include in controllers that accept file uploads, then use before_action. +# +# Example: +# include UploadValidatable +# before_action -> { validate_upload(:file, max_size: 50.megabytes, allowed_types: %w[.xml]) }, only: :create +# +module UploadValidatable + extend ActiveSupport::Concern + + private + + # Validates an uploaded file's size and extension. + # Renders 422 JSON and halts if validation fails. + def validate_upload(param_name, max_size:, allowed_types: nil) + file = params[param_name] + return if file.blank? # let controller's own require/presence check handle missing files + + validate_upload_size(file, max_size) && validate_upload_type(file, allowed_types) + end + + def validate_upload_size(file, max_size) # rubocop:disable Naming/PredicateMethod + return true if file.size <= max_size + + render json: { + toast: { + title: 'Upload rejected', + message: "File exceeds maximum size of #{ActiveSupport::NumberHelper.number_to_human_size(max_size)}.", + variant: 'danger' + } + }, status: :unprocessable_entity + false + end + + def validate_upload_type(file, allowed_types) # rubocop:disable Naming/PredicateMethod + return true if allowed_types.blank? + + ext = File.extname(file.original_filename).downcase + return true if allowed_types.include?(ext) + + render json: { + toast: { + title: 'Upload rejected', + message: "Invalid file type '#{ext}'. Allowed: #{allowed_types.join(', ')}.", + variant: 'danger' + } + }, status: :unprocessable_entity + false + end +end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 39e5dab7e..346a296aa 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -6,6 +6,7 @@ class ProjectsController < ApplicationController include Exportable include ProjectMemberConstants + include UploadValidatable IMPORT_ERROR_TITLE = 'Import error' @@ -16,6 +17,8 @@ class ProjectsController < ApplicationController before_action :authorize_logged_in, only: %i[index search] before_action :authorize_admin_or_create_permission_enabled, only: %i[create create_from_backup] before_action :check_permission_to_update, only: %i[update] + before_action -> { validate_upload(:file, max_size: 100.megabytes, allowed_types: %w[.zip]) }, + only: %i[import_backup create_from_backup] def index @projects = current_user.available_projects.eager_load(:memberships).alphabetical.as_json(methods: %i[memberships]) diff --git a/app/controllers/security_requirements_guides_controller.rb b/app/controllers/security_requirements_guides_controller.rb index cf3b1e4ef..860d7c8ee 100644 --- a/app/controllers/security_requirements_guides_controller.rb +++ b/app/controllers/security_requirements_guides_controller.rb @@ -2,7 +2,10 @@ # Controller for SecurityRequirementsGuides class SecurityRequirementsGuidesController < ApplicationController + include UploadValidatable + before_action :authorize_admin, only: %i[create destroy] + before_action -> { validate_upload(:file, max_size: 50.megabytes, allowed_types: %w[.xml]) }, only: :create before_action :authorize_logged_in, only: %i[index show export] before_action :security_requirements_guide, only: %i[show destroy export] diff --git a/app/controllers/stigs_controller.rb b/app/controllers/stigs_controller.rb index 8d3ff37dc..fd547e012 100644 --- a/app/controllers/stigs_controller.rb +++ b/app/controllers/stigs_controller.rb @@ -2,7 +2,10 @@ # Controller for Stigs class StigsController < ApplicationController + include UploadValidatable + before_action :authorize_admin, only: %i[create destroy] + before_action -> { validate_upload(:file, max_size: 50.megabytes, allowed_types: %w[.xml]) }, only: :create before_action :authorize_logged_in, only: %i[index show export] before_action :set_stig, only: %i[show destroy export] diff --git a/app/models/component.rb b/app/models/component.rb index d82681605..15daf62d8 100644 --- a/app/models/component.rb +++ b/app/models/component.rb @@ -68,6 +68,10 @@ class Component < ApplicationRecord validates_with PrefixValidator validates :name, :prefix, :title, presence: true + validates :name, length: { maximum: 255 } + validates :prefix, length: { maximum: 10 } + validates :title, length: { maximum: 500 } + validates :description, length: { maximum: 5000 } validate :associated_component_must_be_released, :rules_must_be_locked_to_release_component, :cannot_unrelease_component, diff --git a/app/models/disa_rule_description.rb b/app/models/disa_rule_description.rb index 0ce8f47a0..1939ca0ce 100644 --- a/app/models/disa_rule_description.rb +++ b/app/models/disa_rule_description.rb @@ -26,7 +26,7 @@ def self.from_mapping(disa_rule_description_mapping) begin # Customize the Nokogiri parser options to attempt to recover from syntax errors while # also disabling character entity parsing. - options = Nokogiri::XML::ParseOptions::RECOVER | Nokogiri::XML::ParseOptions::NOENT + options = Nokogiri::XML::ParseOptions::RECOVER | Nokogiri::XML::ParseOptions::NONET # Parse the XML with custom options doc = Nokogiri::XML(disa_rule_description_mapping, nil, nil, options) # Convert the Nokogiri document to a Ruby Hash diff --git a/app/models/project.rb b/app/models/project.rb index b2f01c483..7c0cbe490 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -16,7 +16,8 @@ class Project < ApplicationRecord has_one :project_metadata, dependent: :destroy accepts_nested_attributes_for :project_metadata, :memberships - validates :name, presence: true + validates :name, presence: true, length: { maximum: 255 } + validates :description, length: { maximum: 5000 } scope :alphabetical, -> { order(:name) } diff --git a/config/initializers/nokogiri_security.rb b/config/initializers/nokogiri_security.rb new file mode 100644 index 000000000..d4375f2d2 --- /dev/null +++ b/config/initializers/nokogiri_security.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Patch HappyMapper to use NONET parse option, preventing XML external entity +# attacks and SSRF via external DTD fetching. +# +# HappyMapper defaults to Nokogiri::XML::ParseOptions::STRICT (0), which allows +# network access during XML parsing. Adding NONET blocks all network fetches. +module HappyMapperNonetPatch + def parse(xml, options = {}) + if xml.is_a?(String) + # Pre-parse with NONET to block network access, then let HappyMapper process + doc = Nokogiri::XML(xml) do |config| + config.nonet + config.strict + end + super(doc, options) + else + super + end + end +end + +# Apply the patch to HappyMapper's class-level parse +HappyMapper::ClassMethods.prepend(HappyMapperNonetPatch) diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 000000000..7b7295857 --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Rate limiting configuration using rack-attack. +# Protects against brute-force login attempts and upload abuse. +# +# Uses Rails.cache as the backing store (default: MemoryStore in dev, configurable in production). + +module Rack + # Rate limiting rules for login and upload endpoints. + class Attack + ### Throttle login attempts ### + # 5 attempts per 60 seconds per IP address + throttle('logins/ip', limit: 5, period: 60.seconds) do |req| + req.ip if req.path == '/users/sign_in' && req.post? + end + + # 5 attempts per 60 seconds per email (prevents credential stuffing across IPs) + throttle('logins/email', limit: 5, period: 60.seconds) do |req| + if req.path == '/users/sign_in' && req.post? + # Normalize email to prevent bypass via case/whitespace + req.params.dig('user', 'email')&.strip&.downcase + end + end + + ### Throttle file uploads ### + # 10 uploads per 60 seconds per user session (generous for batch operations) + throttle('uploads/ip', limit: 10, period: 60.seconds) do |req| + upload_paths = %w[/stigs /srgs] + backup_paths = req.path.match?(%r{/projects/\d+/(import_backup|components)}) || + req.path == '/projects/create_from_backup' + + req.ip if req.post? && (upload_paths.include?(req.path) || backup_paths) + end + + ### Custom throttle response ### + self.throttled_responder = lambda do |_req| + [ + 429, + { 'Content-Type' => 'application/json' }, + [{ toast: { title: 'Rate limited', message: 'Too many requests. Please wait and try again.', + variant: 'danger' } }.to_json] + ] + end + end +end diff --git a/spec/models/disa_rule_description_spec.rb b/spec/models/disa_rule_description_spec.rb new file mode 100644 index 000000000..e91ae4995 --- /dev/null +++ b/spec/models/disa_rule_description_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe DisaRuleDescription do + describe '.from_mapping' do + it 'parses a valid DISA rule description' do + xml = 'This is a test.' \ + '' \ + '' \ + 'false' \ + '' \ + '' \ + '' \ + '' \ + '' \ + '' \ + '' + + result = described_class.from_mapping(xml.dup) + expect(result).to be_a(Hash) + expect(result[:vuln_discussion]).to eq('This is a test.') + expect(result[:documentable]).to eq('false') + end + + it 'does NOT expand XML external entities (XXE prevention)' do + # An attacker could craft a STIG XML with an XXE payload in the description field. + # If NOENT is enabled, Nokogiri will expand entities like &xxe; to file contents. + # This test ensures entity expansion is blocked. + xxe_payload = ']>' \ + '&xxe;' + + result = described_class.from_mapping(xxe_payload.dup) + + # The entity should NOT be expanded to file contents. + # With NONET and no NOENT, the entity reference is either stripped or left unexpanded. + # In no case should /etc/passwd contents appear. + expect(result).to be_nil.or(satisfy { |r| + vuln = r[:vuln_discussion] + vuln.nil? || (vuln.exclude?('root:') && vuln.exclude?('/bin/')) + }) + end + + it 'does NOT fetch external DTDs (NONET flag)' do + # External DTD fetching could be used for SSRF or data exfiltration. + # The NONET flag prevents any network access during XML parsing. + external_dtd = '' \ + 'Test' + + # Should not raise a network error or hang — NONET blocks it silently with RECOVER + result = described_class.from_mapping(external_dtd.dup) + # As long as it doesn't hang or fetch, we're good + expect(result).to be_nil.or(be_a(Hash)) + end + end +end diff --git a/spec/models/input_length_limits_spec.rb b/spec/models/input_length_limits_spec.rb new file mode 100644 index 000000000..6ef116b6f --- /dev/null +++ b/spec/models/input_length_limits_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENTS: +# - Project and Component metadata fields have maximum length limits +# - Prevents resource exhaustion from excessively long strings + +RSpec.describe 'Input length limits' do + describe Project do + it { is_expected.to validate_length_of(:name).is_at_most(255) } + it { is_expected.to validate_length_of(:description).is_at_most(5000) } + end + + describe Component do + it { is_expected.to validate_length_of(:name).is_at_most(255) } + it { is_expected.to validate_length_of(:prefix).is_at_most(10) } + it { is_expected.to validate_length_of(:title).is_at_most(500) } + it { is_expected.to validate_length_of(:description).is_at_most(5000) } + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 9ce9482a4..4a5dba07e 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -93,6 +93,8 @@ config.before do ActionMailer::Base.deliveries.clear + # Reset rack-attack cache to prevent 429s from bleeding between tests + Rack::Attack.reset! if defined?(Rack::Attack) end # System specs and JS-tagged specs use a separate browser thread that can't diff --git a/spec/requests/rack_attack_spec.rb b/spec/requests/rack_attack_spec.rb new file mode 100644 index 000000000..e1dee0ff0 --- /dev/null +++ b/spec/requests/rack_attack_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENTS: +# - Login attempts are throttled to 5 per 60 seconds per IP +# - Login attempts are throttled to 5 per 60 seconds per email +# - File uploads are throttled to 10 per 60 seconds per IP +# - Throttled responses return 429 with a JSON error message + +RSpec.describe 'Rack::Attack throttling' do + before do + Rails.application.reload_routes! + # Clear rack-attack cache between tests + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + Rack::Attack.reset! + end + + describe 'login throttling' do + it 'allows 5 login attempts then returns 429' do + 5.times do |i| + post '/users/sign_in', + params: { user: { email: "test#{i}@example.com", password: 'wrong' } }, + headers: { 'REMOTE_ADDR' => '1.2.3.4' } + expect(response.status).not_to eq(429), "Request #{i + 1} was throttled unexpectedly" + end + + # 6th attempt should be throttled + post '/users/sign_in', + params: { user: { email: 'test@example.com', password: 'wrong' } }, + headers: { 'REMOTE_ADDR' => '1.2.3.4' } + expect(response).to have_http_status(:too_many_requests) + expect(response.parsed_body.dig('toast', 'title')).to eq('Rate limited') + end + + it 'throttles by email independently of IP' do + 5.times do |i| + post '/users/sign_in', + params: { user: { email: 'target@example.com', password: 'wrong' } }, + headers: { 'REMOTE_ADDR' => "10.0.0.#{i + 1}" } + end + + # 6th attempt with same email from different IP should be throttled + post '/users/sign_in', + params: { user: { email: 'target@example.com', password: 'wrong' } }, + headers: { 'REMOTE_ADDR' => '10.0.0.99' } + expect(response).to have_http_status(:too_many_requests) + end + end +end diff --git a/spec/requests/upload_validation_spec.rb b/spec/requests/upload_validation_spec.rb new file mode 100644 index 000000000..2268fd8f1 --- /dev/null +++ b/spec/requests/upload_validation_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Tests for upload size and content-type validation across all upload endpoints. +# REQUIREMENTS: +# - Uploads exceeding max size are rejected with 422 +# - Uploads with wrong file extension are rejected with 422 +# - Valid uploads pass through to normal processing + +RSpec.describe 'Upload validation' do + let(:admin) { create(:user, admin: true) } + + before do + Rails.application.reload_routes! + sign_in admin + end + + # Helper to create an uploaded file of a specific size + def oversized_file(size_bytes, filename: 'test.xml', content_type: 'application/xml') + content = 'x' * size_bytes + Rack::Test::UploadedFile.new( + StringIO.new(content), + content_type, + original_filename: filename + ) + end + + def small_file(filename: 'test.xml', content_type: 'application/xml', content: '') + Rack::Test::UploadedFile.new( + StringIO.new(content), + content_type, + original_filename: filename + ) + end + + describe 'STIG upload (POST /stigs)' do + it 'rejects files exceeding 50 MB' do + file = oversized_file(51.megabytes) + post '/stigs', params: { file: file }, headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('exceeds maximum size') + end + + it 'rejects non-XML files' do + file = small_file(filename: 'stig.pdf', content_type: 'application/pdf') + post '/stigs', params: { file: file }, headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('Invalid file type') + end + end + + describe 'SRG upload (POST /srgs)' do + it 'rejects files exceeding 50 MB' do + file = oversized_file(51.megabytes) + post '/srgs', params: { file: file }, headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('exceeds maximum size') + end + + it 'rejects non-XML files' do + file = small_file(filename: 'srg.csv', content_type: 'text/csv') + post '/srgs', params: { file: file }, headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('Invalid file type') + end + end + + describe 'Backup import (POST /projects/:id/import_backup)' do + let(:project) { create(:project) } + + before do + create(:membership, :admin, user: admin, membership: project) + end + + it 'rejects files exceeding 100 MB' do + file = oversized_file(101.megabytes, filename: 'backup.zip', content_type: 'application/zip') + post "/projects/#{project.id}/import_backup", params: { file: file }, + headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('exceeds maximum size') + end + + it 'rejects non-ZIP files' do + file = small_file(filename: 'backup.xml', content_type: 'application/xml') + post "/projects/#{project.id}/import_backup", params: { file: file }, + headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('Invalid file type') + end + end + + describe 'Backup create (POST /projects/create_from_backup)' do + it 'rejects files exceeding 100 MB' do + file = oversized_file(101.megabytes, filename: 'backup.zip', content_type: 'application/zip') + post '/projects/create_from_backup', params: { file: file }, + headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('exceeds maximum size') + end + + it 'rejects non-ZIP files' do + file = small_file(filename: 'backup.txt', content_type: 'text/plain') + post '/projects/create_from_backup', params: { file: file }, + headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('Invalid file type') + end + end + + describe 'Component spreadsheet import (POST /projects/:id/components)' do + let_it_be(:srg) { create(:security_requirements_guide) } + let(:project) { create(:project) } + + before do + create(:membership, :admin, user: admin, membership: project) + end + + it 'rejects files exceeding 50 MB' do + file = oversized_file(51.megabytes, filename: 'import.xlsx', + content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + post "/projects/#{project.id}/components", + params: { component: { file: file, name: 'Test', prefix: 'TEST-00', title: 'Test', + security_requirements_guide_id: srg.id } }, + headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('exceeds maximum size') + end + + it 'rejects non-spreadsheet files' do + file = small_file(filename: 'import.xml', content_type: 'application/xml') + post "/projects/#{project.id}/components", + params: { component: { file: file, name: 'Test', prefix: 'TEST-00', title: 'Test', + security_requirements_guide_id: srg.id } }, + headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig('toast', 'message')).to include('Invalid file type') + end + end +end From 30dd879375843adb602a9209c15b16778618f310 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 21 Feb 2026 22:42:52 -0500 Subject: [PATCH 302/428] feat: frontend form validation composable - useFormValidation composable for Vue 2 with real-time validation - Supports required, minLength, maxLength, pattern rules - Tests for all validation scenarios Authored by: Aaron Lippold --- .../composables/useFormValidation.js | 169 ++++++++ .../composables/useFormValidation.spec.js | 384 ++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 app/javascript/composables/useFormValidation.js create mode 100644 spec/javascript/composables/useFormValidation.spec.js diff --git a/app/javascript/composables/useFormValidation.js b/app/javascript/composables/useFormValidation.js new file mode 100644 index 000000000..d9fe45957 --- /dev/null +++ b/app/javascript/composables/useFormValidation.js @@ -0,0 +1,169 @@ +/** + * useFormValidation — lightweight form validation composable for Vue 2.7 + * + * DRY validation that mirrors backend model validations. No external dependencies. + * Works with BootstrapVue's :state prop (true/false/null) and . + * + * Usage: + * const { validate, fieldState, fieldError, isValid, touch, reset } = useFormValidation({ + * name: { value: () => form.name, rules: { required: true } }, + * prefix: { value: () => form.prefix, rules: { required: true, prefix: true } }, + * }); + * + * // In template: + * + * + * + */ +import { ref, computed } from "vue"; + +// ─── Validation rules ─────────────────────────────────────── +// Each rule returns an error message string or null if valid. +const RULES = { + required: (value) => { + if (value === null || value === undefined) return "This field is required"; + if (typeof value === "string" && !value.trim()) return "This field is required"; + return null; + }, + + prefix: (value) => { + if (!value) return null; // let required handle empty + return /^\w{4}-\w{2}$/.test(value) ? null : "Prefix must be of the form AAAA-00"; + }, + + email: (value) => { + if (!value) return null; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : "Please enter a valid email address"; + }, + + minLength: (min) => (value) => { + if (!value) return null; + return value.length >= min ? null : `Must be at least ${min} characters`; + }, + + pattern: (regex, message) => (value) => { + if (!value) return null; + return regex.test(value) ? null : message; + }, +}; + +/** + * Create a form validation instance. + * + * @param {Object} fieldDefs - Field definitions keyed by field name. + * Each value: { value: () => currentValue, rules: { required: true, prefix: true, ... } } + * @returns {Object} Validation API + */ +export function useFormValidation(fieldDefs) { + // Track which fields the user has interacted with + const touched = ref({}); + + // Validate a single field, returns first error message or null + function validateField(fieldName) { + const def = fieldDefs[fieldName]; + if (!def) return null; + + const value = typeof def.value === "function" ? def.value() : def.value; + const rules = def.rules || {}; + + for (const [ruleName, ruleConfig] of Object.entries(rules)) { + if (!ruleConfig) continue; // rule disabled (e.g., required: false) + + let validator; + if (typeof ruleConfig === "function") { + // Custom inline validator: { custom: (v) => error|null } + validator = ruleConfig; + } else if (typeof RULES[ruleName] === "function") { + // Parameterized rules return a validator function + const rule = RULES[ruleName]; + if (typeof ruleConfig === "boolean") { + validator = rule; + } else { + // e.g., minLength: 5 → RULES.minLength(5) returns a function + validator = rule(ruleConfig); + } + } else { + continue; + } + + // If validator itself returned a function (parameterized), call with value + const error = typeof validator === "function" ? validator(value) : null; + if (error) return error; + } + + return null; + } + + // Mark a field as touched (typically on blur) + function touch(fieldName) { + touched.value = { ...touched.value, [fieldName]: true }; + } + + // Mark all fields as touched (typically on submit attempt) + function touchAll() { + const allTouched = {}; + for (const key of Object.keys(fieldDefs)) { + allTouched[key] = true; + } + touched.value = allTouched; + } + + // Reset all touched state + function reset() { + touched.value = {}; + } + + // Get BootstrapVue :state for a field + // Returns: null (untouched), true (valid), false (invalid) + function fieldState(fieldName) { + if (!touched.value[fieldName]) return null; + return validateField(fieldName) === null; + } + + // Get error message for a field (only when touched) + function fieldError(fieldName) { + if (!touched.value[fieldName]) return ""; + return validateField(fieldName) || ""; + } + + // Validate all fields, returns true if all pass + function validate() { + touchAll(); + for (const fieldName of Object.keys(fieldDefs)) { + if (validateField(fieldName) !== null) return false; + } + return true; + } + + // Computed: are all fields currently valid? + const isValid = computed(() => { + for (const fieldName of Object.keys(fieldDefs)) { + if (validateField(fieldName) !== null) return false; + } + return true; + }); + + // Get all current errors (for debugging or summary display) + function errors() { + const result = {}; + for (const fieldName of Object.keys(fieldDefs)) { + const error = validateField(fieldName); + if (error) result[fieldName] = error; + } + return result; + } + + return { + validate, + fieldState, + fieldError, + isValid, + touch, + touchAll, + reset, + errors, + }; +} + +// Export rules for direct use in custom validators +export { RULES }; diff --git a/spec/javascript/composables/useFormValidation.spec.js b/spec/javascript/composables/useFormValidation.spec.js new file mode 100644 index 000000000..4188c653e --- /dev/null +++ b/spec/javascript/composables/useFormValidation.spec.js @@ -0,0 +1,384 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useFormValidation, RULES } from "@/composables/useFormValidation"; + +// ─── Requirements ──────────────────────────────────────────── +// 1. validate() touches all fields and returns true/false +// 2. fieldState() returns null (untouched), true (valid), false (invalid) +// 3. fieldError() returns error message only when touched +// 4. touch() marks a single field as interacted-with +// 5. reset() clears all touched state +// 6. isValid computed reflects current validity without touching +// 7. Rules: required, prefix, email, minLength, pattern, custom functions +// 8. Multiple rules on one field: first failure wins +// ───────────────────────────────────────────────────────────── + +describe("useFormValidation", () => { + // ─── Core API ───────────────────────────────────────────── + + describe("untouched fields", () => { + it("fieldState returns null for untouched fields", () => { + const { fieldState } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + }); + expect(fieldState("name")).toBeNull(); + }); + + it("fieldError returns empty string for untouched fields", () => { + const { fieldError } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + }); + expect(fieldError("name")).toBe(""); + }); + }); + + describe("touch()", () => { + it("marks a field as touched, enabling validation display", () => { + const { fieldState, touch } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + }); + touch("name"); + expect(fieldState("name")).toBe(false); + }); + + it("shows error message after touch", () => { + const { fieldError, touch } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + }); + touch("name"); + expect(fieldError("name")).toBe("This field is required"); + }); + + it("shows valid state for valid touched field", () => { + const { fieldState, touch } = useFormValidation({ + name: { value: () => "My Project", rules: { required: true } }, + }); + touch("name"); + expect(fieldState("name")).toBe(true); + }); + }); + + describe("validate()", () => { + it("returns true when all fields are valid", () => { + const { validate } = useFormValidation({ + name: { value: () => "Test", rules: { required: true } }, + }); + expect(validate()).toBe(true); + }); + + it("returns false when any field is invalid", () => { + const { validate } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + title: { value: () => "OK", rules: { required: true } }, + }); + expect(validate()).toBe(false); + }); + + it("touches all fields so errors become visible", () => { + const { validate, fieldState } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + title: { value: () => "", rules: { required: true } }, + }); + // Before validate, fields are untouched + expect(fieldState("name")).toBeNull(); + validate(); + // After validate, fields show errors + expect(fieldState("name")).toBe(false); + expect(fieldState("title")).toBe(false); + }); + }); + + describe("reset()", () => { + it("clears all touched state", () => { + const { touch, reset, fieldState } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + }); + touch("name"); + expect(fieldState("name")).toBe(false); + reset(); + expect(fieldState("name")).toBeNull(); + }); + }); + + describe("isValid (computed)", () => { + it("returns true when all fields pass", () => { + const { isValid } = useFormValidation({ + name: { value: () => "Test", rules: { required: true } }, + }); + expect(isValid.value).toBe(true); + }); + + it("returns false when any field fails", () => { + const { isValid } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + }); + expect(isValid.value).toBe(false); + }); + + it("does not require touching fields", () => { + const { isValid, fieldState } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + }); + // isValid reflects reality, fieldState still null (untouched) + expect(isValid.value).toBe(false); + expect(fieldState("name")).toBeNull(); + }); + }); + + describe("errors()", () => { + it("returns all current errors regardless of touched state", () => { + const { errors } = useFormValidation({ + name: { value: () => "", rules: { required: true } }, + title: { value: () => "OK", rules: { required: true } }, + prefix: { value: () => "bad", rules: { required: true, prefix: true } }, + }); + const errs = errors(); + expect(errs.name).toBe("This field is required"); + expect(errs.title).toBeUndefined(); + expect(errs.prefix).toBe("Prefix must be of the form AAAA-00"); + }); + }); + + // ─── Built-in Rules ─────────────────────────────────────── + + describe("required rule", () => { + it("fails for empty string", () => { + expect(RULES.required("")).toBe("This field is required"); + }); + + it("fails for whitespace-only string", () => { + expect(RULES.required(" ")).toBe("This field is required"); + }); + + it("fails for null", () => { + expect(RULES.required(null)).toBe("This field is required"); + }); + + it("fails for undefined", () => { + expect(RULES.required(undefined)).toBe("This field is required"); + }); + + it("passes for non-empty string", () => { + expect(RULES.required("hello")).toBeNull(); + }); + }); + + describe("prefix rule", () => { + it("passes for valid prefix AAAA-00", () => { + expect(RULES.prefix("RHEL-09")).toBeNull(); + }); + + it("passes for alphanumeric + underscore", () => { + expect(RULES.prefix("AB_D-0X")).toBeNull(); + }); + + it("fails for too-short prefix", () => { + expect(RULES.prefix("AB-01")).toBeTruthy(); + }); + + it("fails for missing dash", () => { + expect(RULES.prefix("ABCD01")).toBeTruthy(); + }); + + it("fails for too many chars after dash", () => { + expect(RULES.prefix("ABCD-012")).toBeTruthy(); + }); + + it("returns null for empty (let required handle it)", () => { + expect(RULES.prefix("")).toBeNull(); + }); + }); + + describe("email rule", () => { + it("passes for valid email", () => { + expect(RULES.email("user@example.com")).toBeNull(); + }); + + it("fails for missing @", () => { + expect(RULES.email("userexample.com")).toBeTruthy(); + }); + + it("fails for missing domain", () => { + expect(RULES.email("user@")).toBeTruthy(); + }); + + it("returns null for empty (let required handle it)", () => { + expect(RULES.email("")).toBeNull(); + }); + }); + + describe("minLength rule", () => { + it("passes when value meets minimum", () => { + const validator = RULES.minLength(5); + expect(validator("hello")).toBeNull(); + }); + + it("fails when value is too short", () => { + const validator = RULES.minLength(5); + expect(validator("hi")).toBe("Must be at least 5 characters"); + }); + + it("returns null for empty (let required handle it)", () => { + const validator = RULES.minLength(5); + expect(validator("")).toBeNull(); + }); + }); + + describe("pattern rule", () => { + it("passes when value matches pattern", () => { + const validator = RULES.pattern(/^\d{3}$/, "Must be 3 digits"); + expect(validator("123")).toBeNull(); + }); + + it("fails with custom message", () => { + const validator = RULES.pattern(/^\d{3}$/, "Must be 3 digits"); + expect(validator("12")).toBe("Must be 3 digits"); + }); + }); + + // ─── Multiple rules ─────────────────────────────────────── + + describe("multiple rules on one field", () => { + it("required fires before prefix for empty value", () => { + const { fieldError, touch } = useFormValidation({ + prefix: { value: () => "", rules: { required: true, prefix: true } }, + }); + touch("prefix"); + expect(fieldError("prefix")).toBe("This field is required"); + }); + + it("prefix fires for non-empty invalid value", () => { + const { fieldError, touch } = useFormValidation({ + prefix: { value: () => "bad", rules: { required: true, prefix: true } }, + }); + touch("prefix"); + expect(fieldError("prefix")).toBe("Prefix must be of the form AAAA-00"); + }); + + it("both pass for valid value", () => { + const { fieldState, touch } = useFormValidation({ + prefix: { value: () => "RHEL-09", rules: { required: true, prefix: true } }, + }); + touch("prefix"); + expect(fieldState("prefix")).toBe(true); + }); + }); + + // ─── Custom inline validators ───────────────────────────── + + describe("custom inline validator", () => { + it("accepts a function as a rule", () => { + const { fieldError, touch } = useFormValidation({ + age: { + value: () => 15, + rules: { + custom: (v) => (v >= 18 ? null : "Must be 18 or older"), + }, + }, + }); + touch("age"); + expect(fieldError("age")).toBe("Must be 18 or older"); + }); + + it("returns null for passing custom rule", () => { + const { fieldState, touch } = useFormValidation({ + age: { + value: () => 21, + rules: { + custom: (v) => (v >= 18 ? null : "Must be 18 or older"), + }, + }, + }); + touch("age"); + expect(fieldState("age")).toBe(true); + }); + }); + + // ─── Reactive value functions ───────────────────────────── + + describe("reactive value functions", () => { + it("re-evaluates value function on each call", () => { + let currentValue = ""; + const { fieldState, touch } = useFormValidation({ + name: { value: () => currentValue, rules: { required: true } }, + }); + touch("name"); + expect(fieldState("name")).toBe(false); + + currentValue = "Now filled"; + expect(fieldState("name")).toBe(true); + }); + }); + + // ─── Edge cases ─────────────────────────────────────────── + + describe("edge cases", () => { + it("fieldState returns null for unknown field", () => { + const { fieldState } = useFormValidation({}); + expect(fieldState("nonexistent")).toBeNull(); + }); + + it("fieldError returns empty for unknown field", () => { + const { fieldError } = useFormValidation({}); + expect(fieldError("nonexistent")).toBe(""); + }); + + it("handles field with no rules", () => { + const { fieldState, touch } = useFormValidation({ + name: { value: () => "", rules: {} }, + }); + touch("name"); + expect(fieldState("name")).toBe(true); + }); + + it("handles disabled rules (required: false)", () => { + const { fieldState, touch } = useFormValidation({ + name: { value: () => "", rules: { required: false } }, + }); + touch("name"); + expect(fieldState("name")).toBe(true); + }); + }); + + // ─── Real-world: Component form ─────────────────────────── + + describe("component form scenario", () => { + let form; + let validation; + + beforeEach(() => { + form = { name: "", prefix: "", title: "" }; + validation = useFormValidation({ + name: { value: () => form.name, rules: { required: true } }, + prefix: { value: () => form.prefix, rules: { required: true, prefix: true } }, + title: { value: () => form.title, rules: { required: true } }, + }); + }); + + it("validate() fails when all fields empty", () => { + expect(validation.validate()).toBe(false); + }); + + it("validate() passes with valid data", () => { + form.name = "My Component"; + form.prefix = "RHEL-09"; + form.title = "Red Hat Enterprise Linux 9"; + expect(validation.validate()).toBe(true); + }); + + it("shows prefix format error for invalid prefix", () => { + form.name = "Test"; + form.prefix = "bad"; + form.title = "Test"; + validation.validate(); + expect(validation.fieldError("prefix")).toBe("Prefix must be of the form AAAA-00"); + expect(validation.fieldState("name")).toBe(true); + }); + + it("reset clears errors after failed validation", () => { + validation.validate(); + expect(validation.fieldState("name")).toBe(false); + validation.reset(); + expect(validation.fieldState("name")).toBeNull(); + }); + }); +}); From f20e942bd316d2e88fa7c6381cc5bf703783280a Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 21 Feb 2026 22:43:01 -0500 Subject: [PATCH 303/428] docs: security controls, configuration, and v2.3.1 release notes - Updated AC-10 session limits with configurable max_sessions - Added session tracking, PBKDF2, upload validation, rate limiting docs - Updated compliance matrix (removed resolved gaps) - Added v2.3.1 release notes - Updated schema.rb for new migrations Authored by: Aaron Lippold --- Gemfile | 2 +- db/schema.rb | 30 ++++- docs/deployment/bare-metal.md | 2 +- docs/deployment/kubernetes.md | 12 -- docs/development/architecture.md | 67 +++-------- docs/development/documentation.md | 2 +- docs/development/setup.md | 35 +++--- docs/development/testing.md | 68 ++--------- docs/getting-started/configuration.md | 37 +++++- docs/getting-started/environment-variables.md | 7 ++ docs/index.md | 5 +- docs/release-notes/v2.3.1.md | 16 +++ docs/security/compliance.md | 47 ++++++-- docs/security/security-controls.md | 112 +++++++++--------- 14 files changed, 230 insertions(+), 212 deletions(-) diff --git a/Gemfile b/Gemfile index c4dc22eb8..f766645a8 100644 --- a/Gemfile +++ b/Gemfile @@ -25,8 +25,8 @@ gem 'devise', '~> 4.9' # PBKDF2-SHA512 password hashing for FIPS 140-2 compliance gem 'devise-encryptable' # Session limiting (AC-10), session tracking, and server-side session store -gem 'devise-security', github: 'mitre/devise-security', branch: 'main' gem 'activerecord-session_store' +gem 'devise-security', github: 'mitre/devise-security', branch: 'main' # Use Omniauth to support additional login providers gem 'omniauth', '~> 2.1' # LDAP Auth diff --git a/db/schema.rb b/db/schema.rb index 39b639326..7e9a81c45 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_02_20_145113) do +ActiveRecord::Schema[8.0].define(version: 2026_02_22_010000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -288,6 +288,32 @@ t.index ["title"], name: "index_srgs_on_title_trigram", opclass: :gin_trgm_ops, using: :gin end + create_table "session_histories", force: :cascade do |t| + t.string "token", null: false + t.inet "ip_address" + t.string "user_agent" + t.datetime "last_accessed_at", null: false + t.boolean "active", default: true, null: false + t.string "owner_type", null: false + t.bigint "owner_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active"], name: "index_session_histories_on_active" + t.index ["ip_address"], name: "index_session_histories_on_ip_address" + t.index ["last_accessed_at"], name: "index_session_histories_on_last_accessed_at" + t.index ["owner_type", "owner_id"], name: "index_session_histories_on_owner" + t.index ["token"], name: "index_session_histories_on_token", unique: true + end + + create_table "sessions", force: :cascade do |t| + t.string "session_id", null: false + t.text "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["session_id"], name: "index_sessions_on_session_id", unique: true + t.index ["updated_at"], name: "index_sessions_on_updated_at" + end + create_table "stigs", force: :cascade do |t| t.string "stig_id" t.string "title" @@ -328,6 +354,8 @@ t.integer "failed_attempts", default: 0, null: false t.string "unlock_token" t.datetime "locked_at" + t.string "password_salt" + t.string "unique_session_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true, where: "((provider IS NOT NULL) AND (uid IS NOT NULL))" diff --git a/docs/deployment/bare-metal.md b/docs/deployment/bare-metal.md index 4fa303701..25d26bc4e 100644 --- a/docs/deployment/bare-metal.md +++ b/docs/deployment/bare-metal.md @@ -177,7 +177,7 @@ bundle exec rails c # In Rails console: User.create!( email: 'admin@example.com', - password: 'secure_password', + password: '1qaz!QAZ1qaz!QAZ', # Must comply with DoD 2222 policy (15+ chars, 2 of each type) admin: true, confirmed_at: Time.now ) diff --git a/docs/deployment/kubernetes.md b/docs/deployment/kubernetes.md index 5380209e5..4d6bee58f 100644 --- a/docs/deployment/kubernetes.md +++ b/docs/deployment/kubernetes.md @@ -481,18 +481,6 @@ spec: ## Monitoring -### Prometheus Metrics - -Add annotations for Prometheus scraping: - -```yaml -metadata: - annotations: - prometheus.io/scrape: "true" - prometheus.io/port: "3000" - prometheus.io/path: "/metrics" -``` - ### Health Checks Vulcan provides health endpoints for Kubernetes probes: diff --git a/docs/development/architecture.md b/docs/development/architecture.md index ba38dd8b1..85dbcb0d5 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -16,11 +16,10 @@ Vulcan is a Rails-based web application designed for creating and managing Secur - **Ruby 3.4.8** - Programming language - **Rails 8.0.2.1** - Web application framework - **PostgreSQL** - Primary database -- **Redis** - Caching and background jobs (optional) ### Frontend -- **Vue 2.6.11** - Reactive UI framework -- **Bootstrap 4.4.1** - CSS framework +- **Vue 2.7.16** - Reactive UI framework +- **Bootstrap 4.6.2** - CSS framework - **Bootstrap-Vue 2.13.0** - Vue Bootstrap components - **Turbolinks 5.2.0** - Page navigation optimization - **esbuild** - JavaScript bundling @@ -54,7 +53,7 @@ vulcan/ ## Vue.js Architecture -Vulcan uses a unique approach with 14 separate Vue instances, one per page: +Vulcan uses a unique approach with 16 separate Vue instances, one per page: ```javascript // Each pack file creates its own Vue instance @@ -67,20 +66,22 @@ new Vue({ ``` ### Vue Instances by Page +- `application.js` - Base application with Turbolinks/Rails UJS setup +- `login.js` - Login page - `navbar.js` - Global navigation -- `toaster.js` - Global notifications -- `projects.js` - Projects listing - `project.js` - Single project view +- `project_component.js` - Single component editing - `project_components.js` - Component management -- `project_component.js` - Single component -- `project_component_reviews.js` - Review workflow -- `project_component_history.js` - Audit history -- `project_component_review_summary.js` - Review summary -- `project_members.js` - Team management -- `project_access_requests.js` - Access requests -- `project_import.js` - Import functionality -- `project_export.js` - Export functionality -- `memberships.js` - User memberships +- `projects.js` - Projects listing +- `released_component.js` - Released component view +- `rules.js` - Rule management +- `security_requirements_guides.js` - SRG listing and management +- `srg.js` - Single SRG view +- `stig.js` - Single STIG view +- `stigs.js` - STIGs listing +- `toaster.js` - Global notifications +- `user_profile.js` - User profile management +- `users.js` - User administration ### Benefits of Multiple Vue Instances - Gradual migration path @@ -118,46 +119,14 @@ devise :oidc_authenticatable # OIDC/SAML ```ruby Project has_many :components Component has_many :rules -Rule belongs_to :srg_requirement +Rule belongs_to :srg_rule Component has_many :reviews Review belongs_to :user ``` ## API Design -### RESTful Endpoints -``` -GET /api/v1/projects -POST /api/v1/projects -GET /api/v1/projects/:id -PATCH /api/v1/projects/:id -DELETE /api/v1/projects/:id - -GET /api/v1/projects/:project_id/components -POST /api/v1/projects/:project_id/components -# ... similar patterns for all resources -``` - -### JSON Response Format -```json -{ - "data": { - "id": "123", - "type": "project", - "attributes": { - "name": "Example Project", - "created_at": "2025-01-01T00:00:00Z" - }, - "relationships": { - "components": { - "data": [ - { "id": "456", "type": "component" } - ] - } - } - } -} -``` +Vulcan is not a public REST API. The only external-facing API endpoint is `GET /api/search/global`, used internally by the navbar search. All other data is served via standard Rails HTML responses with JSON used for Vue component data. ## Security Architecture diff --git a/docs/development/documentation.md b/docs/development/documentation.md index 4cd20cb1c..dc254916b 100644 --- a/docs/development/documentation.md +++ b/docs/development/documentation.md @@ -14,7 +14,7 @@ Vulcan uses [VitePress](https://vitepress.dev/) for documentation, which provide **The documentation has its own `package.json` separate from the main application.** This temporary separation exists because: -- **Main Application**: Uses Vue 2.6.11 + Bootstrap 4 +- **Main Application**: Uses Vue 2.7.16 + Bootstrap 4 - **Documentation**: Uses VitePress with Vue 3 This will be consolidated once the main application migrates to Vue 3. diff --git a/docs/development/setup.md b/docs/development/setup.md index 6a4acf8f8..d29d4d83c 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -10,7 +10,6 @@ This guide walks through setting up a local Vulcan development environment. - **Node.js 22 LTS** and **Yarn** package manager - **PostgreSQL 12+** database server - **Git** version control -- **Redis** (optional, for caching) ### Recommended Tools @@ -199,14 +198,14 @@ rails console User.create!( email: 'admin@example.com', - password: 'password123', + password: 'S3cure!#Pass001', admin: true, confirmed_at: Time.now ) User.create!( email: 'user@example.com', - password: 'password123', + password: 'S3cure!#Pass001', confirmed_at: Time.now ) ``` @@ -217,11 +216,13 @@ User.create!( - Go to GitHub Settings > Developer settings > OAuth Apps - Set callback URL: `http://localhost:3000/users/auth/github/callback` -2. Add to `.env.development`: -```bash -VULCAN_ENABLE_GITHUB_AUTH=true -VULCAN_GITHUB_APP_ID=your_client_id -VULCAN_GITHUB_APP_SECRET=your_client_secret +2. Add to `config/vulcan.yml` under the `providers` key: +```yaml +providers: + - { name: 'github', + app_id: 'your_client_id', + app_secret: 'your_client_secret', + args: { scope: 'user:email' } } ``` ## Development Workflow @@ -267,11 +268,11 @@ yarn lint:ci #### Run All Tests ```bash -# Ruby tests -bundle exec rspec +# Ruby tests (use parallel_rspec for full suite — 3-4x faster) +bundle exec parallel_rspec spec/ # JavaScript tests -yarn test +yarn test:unit # Specific test file bundle exec rspec spec/models/user_spec.rb @@ -432,13 +433,7 @@ docker compose exec web bash ### Development Speed -1. **Spring** (Rails 7 and below): -```bash -spring stop -spring status -``` - -2. **Bootsnap** (enabled by default): +1. **Bootsnap** (enabled by default): ```ruby # config/boot.rb require 'bootsnap/setup' @@ -446,7 +441,7 @@ require 'bootsnap/setup' 3. **Parallel Testing**: ```bash -PARALLEL_WORKERS=4 bundle exec rspec +bundle exec parallel_rspec spec/ ``` ### Database Performance @@ -546,7 +541,7 @@ rails generate migration AddAdminToUsers admin:boolean 10.times do |i| User.create!( email: "user#{i}@example.com", - password: 'password123' + password: '1qaz!QAZ1qaz!QAZ' ) end ``` diff --git a/docs/development/testing.md b/docs/development/testing.md index 48edd0764..d4e6bfaab 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -9,15 +9,14 @@ Comprehensive guide for testing Vulcan application code, including unit tests, i - **FactoryBot** - Test data factories - **SimpleCov** - Code coverage reporting - **DatabaseCleaner** - Test database management -- **VCR** - HTTP interaction recording ## Running Tests ### Quick Commands ```bash -# Run all tests -bundle exec rspec +# Run all tests (ALWAYS use parallel_rspec — 3-4x faster than rspec) +bundle exec parallel_rspec spec/ # Run specific test file bundle exec rspec spec/models/user_spec.rb @@ -26,10 +25,7 @@ bundle exec rspec spec/models/user_spec.rb bundle exec rspec spec/models/user_spec.rb:42 # Run tests with coverage -COVERAGE=true bundle exec rspec - -# Run tests in parallel -PARALLEL_WORKERS=4 bundle exec rspec +COVERAGE=true bundle exec parallel_rspec spec/ # Run only failed tests bundle exec rspec --only-failures @@ -170,14 +166,14 @@ RSpec.describe 'User Login', type: :system do driven_by(:selenium_chrome_headless) end - let(:user) { create(:user, password: 'password123') } + let(:user) { create(:user, password: 'S3cure!#Pass001') } scenario 'successful login' do visit root_path click_link 'Sign In' - + fill_in 'Email', with: user.email - fill_in 'Password', with: 'password123' + fill_in 'Password', with: 'S3cure!#Pass001' click_button 'Log in' expect(page).to have_content('Signed in successfully') @@ -256,7 +252,7 @@ describe('ProjectCard', () => { FactoryBot.define do factory :user do sequence(:email) { |n| "user#{n}@example.com" } - password { 'password123' } + password { 'S3cure!#Pass001' } first_name { Faker::Name.first_name } last_name { Faker::Name.last_name } confirmed_at { Time.now } @@ -402,30 +398,6 @@ describe 'ExternalService' do end ``` -### VCR for HTTP Requests - -```ruby -# spec/support/vcr.rb -VCR.configure do |config| - config.cassette_library_dir = 'spec/fixtures/vcr_cassettes' - config.hook_into :webmock - config.ignore_localhost = true - config.configure_rspec_metadata! - - # Filter sensitive data - config.filter_sensitive_data('') { ENV['EXTERNAL_API_KEY'] } -end - -# Usage in tests -describe 'GitHub Integration', vcr: true do - it 'fetches user data' do - # First run records the interaction - # Subsequent runs use the cassette - user = GitHubService.fetch_user('octocat') - expect(user.login).to eq('octocat') - end -end -``` ## Performance Testing @@ -446,26 +418,6 @@ describe 'Performance' do end ``` -### N+1 Query Detection - -```ruby -# Gemfile -group :test do - gem 'bullet' -end - -# spec/rails_helper.rb -if Bullet.enable? - config.before(:each) do - Bullet.start_request - end - - config.after(:each) do - Bullet.perform_out_of_channel_notifications if Bullet.notification? - Bullet.end_request - end -end -``` ## CI/CD Testing @@ -482,7 +434,7 @@ jobs: services: postgres: - image: postgres:14 + image: postgres:16 env: POSTGRES_PASSWORD: postgres options: >- @@ -522,8 +474,8 @@ jobs: env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/test COVERAGE: true - run: bundle exec rspec - + run: bundle exec parallel_rspec spec/ + - name: Upload coverage uses: codecov/codecov-action@v3 with: diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 4a18ec3d8..264e7a600 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -16,6 +16,7 @@ Vulcan can be set up in a few different ways. It can be done by having a vulcan. - [Configure Slack:](#configure-slack) - [Configure Classification Banner:](#configure-classification-banner) Display colored classification/sensitivity banner - [Configure Consent Modal:](#configure-consent-modal) Terms-of-use modal that blocks access until acknowledged +- [Configure Session Limits:](#configure-session-limits) Per-user concurrent session limits (STIG AC-10) - [Configure Account Lockout:](#configure-account-lockout) Lock accounts after failed login attempts (STIG AC-07) - [Configure Password Policy:](#configure-password-policy) Password complexity requirements (DoD 2222 default) @@ -114,6 +115,7 @@ Defaults give you: local login, registration, first-user-becomes-admin, no SMTP, **Optional** (sensible defaults work out of the box): +- Session limits — enabled by default, STIG AC-10 compliant (one session per user) - Account lockout — enabled by default, STIG AC-07 compliant - Password policy — DoD 2222 defaults (15 chars, 2 of each type) - Classification banner — disabled by default, set if required @@ -162,7 +164,7 @@ Use `./setup-docker-secrets.sh` to generate all required secrets automatically, ## Configure LDAP -- **enabled:** `(ENV: ENABLE_LDAP)(default: false)` +- **enabled:** `(ENV: VULCAN_ENABLE_LDAP)(default: false)` - **servers:** - **main:** - **host:** `(ENV: VULCAN_LDAP_HOST)(default: localhost)` @@ -266,6 +268,39 @@ By accessing this system you agree to the following: When you update your terms, increment `VULCAN_CONSENT_VERSION` (e.g., from `1` to `2`). All users will see the modal again on their next visit, regardless of prior acknowledgment. ::: +## Configure Session Limits + +STIG AC-10 compliant per-user concurrent session limits. When a user exceeds the configured maximum number of active sessions, the oldest session is evicted and that user is redirected to the login page. + +Uses `devise-security` `:session_limitable` and `:session_traceable` with `activerecord-session_store` for server-side session tracking. Each login creates a `SessionHistory` record with token, IP, and user agent for audit purposes. + +- **enabled:** Enable per-user session limits. `(ENV: VULCAN_SESSION_LIMITS_ENABLED)(default: true)` +- **max_sessions:** Maximum concurrent sessions per user. `(ENV: VULCAN_MAX_CONCURRENT_SESSIONS)(default: 1)` + +### Example: Strict (default — one session per user) + +```bash +VULCAN_SESSION_LIMITS_ENABLED=true +VULCAN_MAX_CONCURRENT_SESSIONS=1 +``` + +### Example: Allow 3 concurrent sessions + +```bash +VULCAN_SESSION_LIMITS_ENABLED=true +VULCAN_MAX_CONCURRENT_SESSIONS=3 +``` + +### Example: Disabled for Development + +```bash +VULCAN_SESSION_LIMITS_ENABLED=false +``` + +::: tip +Session limits work alongside session timeout (VULCAN_SESSION_TIMEOUT) and account lockout (VULCAN_LOCKOUT_ENABLED) for defense in depth. Session history records are retained for audit trail purposes even after sessions expire. +::: + ## Configure Account Lockout STIG AC-07 compliant account lockout. Locks accounts after consecutive failed login attempts and provides multiple unlock methods. diff --git a/docs/getting-started/environment-variables.md b/docs/getting-started/environment-variables.md index 1fdbb660c..9111db512 100644 --- a/docs/getting-started/environment-variables.md +++ b/docs/getting-started/environment-variables.md @@ -177,6 +177,13 @@ VULCAN_OIDC_REDIRECT_URI=https://vulcan.example.com/users/auth/oidc/callback | `VULCAN_SLACK_API_TOKEN` | Slack API token | - | `xoxb-your-token` | | `VULCAN_SLACK_CHANNEL_ID` | Slack channel ID | - | `C1234567890` | +## Session Limits (STIG AC-10) + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VULCAN_SESSION_LIMITS_ENABLED` | Enable per-user concurrent session limits | `true` | `false` | +| `VULCAN_MAX_CONCURRENT_SESSIONS` | Maximum concurrent sessions per user. Oldest session evicted when exceeded. | `1` | `3` | + ## Account Lockout (STIG AC-07) | Variable | Description | Default | Example | diff --git a/docs/index.md b/docs/index.md index e108a91e8..19da67fc0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -90,14 +90,13 @@ Vulcan bridges the gap between security requirements and practical implementatio
    • Ruby 3.4.8 with Rails 8.0.2.1
    • PostgreSQL 12+
    • -
    • Redis for caching

    Frontend

      -
    • Vue 2.6.11
    • -
    • Bootstrap 4.4.1
    • +
    • Vue 2.7.16
    • +
    • Bootstrap 4.6.2
    • Turbolinks 5.2.0
    diff --git a/docs/release-notes/v2.3.1.md b/docs/release-notes/v2.3.1.md index b03a6e89e..c8ee9550d 100644 --- a/docs/release-notes/v2.3.1.md +++ b/docs/release-notes/v2.3.1.md @@ -18,9 +18,25 @@ This release includes DISA process documentation, SRG ID display in satisfaction - Configurable SSL for Docker deployments (#700, #702) - YAML.safe_load replaces YAML.load_file in SearchAbbreviationService (deserialization safety) - Database deployment safety: removed dangerous `DISABLE_DATABASE_ENVIRONMENT_CHECK` from Docker entrypoint; flag now only used in Heroku review app postdeploy where it's needed for `db:schema:load` +- XXE prevention: removed NOENT flag from XML parser, added NONET to block external entity expansion and network DTD fetching +- Upload validation: file size limits (50 MB XML, 100 MB ZIP, 50 MB spreadsheet) and content-type checks on all upload endpoints +- Rate limiting via rack-attack: login throttling (5/min/IP, 5/min/email), upload throttling (10/min/IP) +- Input length limits on Project and Component metadata fields +- HappyMapper NONET patch prevents SSRF via external DTD URIs in XCCDF parsing ## Features +### Security and Access Control +- Account lockout (Devise `:lockable`) with admin unlock UI, AC-07 compliance +- Configurable concurrent session limits (AC-10) with session history tracking via `devise-security` fork +- PBKDF2-SHA512 password hashing (FIPS 140-2 compliant) with transparent bcrypt migration +- Devise hardening: paranoid mode, DELETE logout (CSRF protection), cookie security (secure + httponly + SameSite), email/password change notifications, 2-hour password reset window +- Classification banner and consent modal +- Per-section rule field locking (lock individual rule sections independently) +- Password complexity policy (configurable DoD 2222 defaults) +- Last-admin protection (prevents demoting/deleting only admin) +- Admin user management (create, edit, delete, lock/unlock accounts) + ### Infrastructure - Unified multi-stage Dockerfile with CLI and improved .dockerignore - Admin bootstrap with first-user-admin and env var support diff --git a/docs/security/compliance.md b/docs/security/compliance.md index 3942728c0..fd658f724 100644 --- a/docs/security/compliance.md +++ b/docs/security/compliance.md @@ -94,7 +94,6 @@ VULCAN_SESSION_TIMEOUT=10m # Required: 10 min for admin, 15 min for users ``` ⚠️ **Known Gaps:** -- Session limit per user (Issue #634) - In development - Logout confirmation message (Issue #635) - In development #### System Use Notification (AC-08) @@ -288,6 +287,23 @@ server { } ``` +#### Input Security Hardening (SI-10) + +**What's Required:** Check validity of information inputs + +**How Vulcan Implements It:** + +| Protection | Implementation | Scope | +|-----------|---------------|-------| +| **XXE Prevention** | `NONET` flag on all XML parsers (Nokogiri + HappyMapper) | XCCDF uploads | +| **File Size Limits** | `before_action` validation: 50 MB XML, 100 MB ZIP, 50 MB spreadsheet | All uploads | +| **Content-Type Validation** | File extension whitelist per endpoint (.xml, .zip, .xlsx/.csv) | All uploads | +| **Rate Limiting** | rack-attack: 5 login attempts/min/IP, 10 uploads/min/IP | Login + uploads | +| **Input Length Limits** | ActiveRecord validations on Project/Component metadata fields | Database writes | + +**Configuration:** +Rate limiting is enabled by default. Thresholds can be adjusted in `config/initializers/rack_attack.rb`. + ## Deployment Configurations ### Production Deployment Checklist @@ -539,7 +555,6 @@ end | Control | Gap | Workaround | Target Resolution | |---------|-----|------------|-------------------| -| AC-10 | No session limits per user | Monitor via SIEM | Q1 2025 (Issue #634) | | AC-12(02) | No logout confirmation | Check audit logs | Q1 2025 (Issue #635) | | AU-05 | No built-in log overflow handling | External log rotation | Use log management system | @@ -554,12 +569,19 @@ end - ✅ Admin user management with last-admin protection - ✅ SMTP-aware Devise views (no silent failures) - ✅ Deny-by-default authorization safety net +- ✅ Account lockout (Devise `:lockable`, AC-07 compliance, admin unlock UI) +- ✅ XXE prevention (NOENT→NONET in XML parsers, HappyMapper NONET patch) +- ✅ Upload validation (file size limits + content-type checks on all endpoints) +- ✅ Rate limiting (rack-attack: login throttling, upload throttling) +- ✅ Input length limits on project and component metadata +- ✅ Per-section rule field locking + +- ✅ Session limits per user (configurable `max_active_sessions`, session history tracking) +- ✅ Logout confirmation (Devise flash notice via Toaster component) +- ✅ Devise hardening (paranoid mode, DELETE logout, cookie security, change notifications) +- ✅ PBKDF2-SHA512 password hashing (FIPS 140-2 compliant, transparent bcrypt migration) **Planned:** -- ⏳ Account lockout UI (Devise `:lockable` — DB columns exist, module not yet enabled) -- ⏳ Session limits per user (Issue #634) -- ⏳ Logout confirmation (Issue #635) -- 📋 FIPS 140-2 cryptography mode - 📋 Built-in MFA for local accounts - 📋 Enhanced RBAC with custom roles @@ -583,8 +605,16 @@ This table provides direct links to the Vulcan source code that implements each | **Password Complexity** | DoD 2222 Policy | [`app/models/concerns/password_complexity_validator.rb`](https://github.com/mitre/vulcan/blob/master/app/models/concerns/password_complexity_validator.rb) | ✅ Implemented | | **Last-Admin Protection** | Prevent Lockout | [`app/controllers/users_controller.rb`](https://github.com/mitre/vulcan/blob/master/app/controllers/users_controller.rb) | ✅ Implemented | | **Admin User Management** | Create/Edit/Delete | [`app/controllers/users_controller.rb`](https://github.com/mitre/vulcan/blob/master/app/controllers/users_controller.rb) | ✅ Implemented | -| **Session Limits** | Per-User Limits | [Issue #634](https://github.com/mitre/vulcan/issues/634) | 🚧 In Development | -| **Logout Message** | Confirmation | [Issue #635](https://github.com/mitre/vulcan/issues/635) | 🚧 In Development | +| **Account Lockout** | AC-07 Compliance | [`app/models/user.rb`](https://github.com/mitre/vulcan/blob/master/app/models/user.rb)
    [`config/initializers/devise.rb`](https://github.com/mitre/vulcan/blob/master/config/initializers/devise.rb) | ✅ Implemented | +| **Upload Validation** | File Size + Content-Type | [`app/controllers/concerns/upload_validatable.rb`](https://github.com/mitre/vulcan/blob/master/app/controllers/concerns/upload_validatable.rb) | ✅ Implemented | +| **Rate Limiting** | Login + Upload Throttling | [`config/initializers/rack_attack.rb`](https://github.com/mitre/vulcan/blob/master/config/initializers/rack_attack.rb) | ✅ Implemented | +| **XXE Prevention** | XML Parser Hardening | [`app/models/disa_rule_description.rb`](https://github.com/mitre/vulcan/blob/master/app/models/disa_rule_description.rb)
    [`config/initializers/nokogiri_security.rb`](https://github.com/mitre/vulcan/blob/master/config/initializers/nokogiri_security.rb) | ✅ Implemented | +| **Section Locking** | Per-Field Rule Locks | [`app/controllers/rules_controller.rb`](https://github.com/mitre/vulcan/blob/master/app/controllers/rules_controller.rb) | ✅ Implemented | +| **Session Limits** | Per-User Limits (AC-10) | [`app/models/user.rb`](https://github.com/mitre/vulcan/blob/master/app/models/user.rb)
    [`config/initializers/devise.rb`](https://github.com/mitre/vulcan/blob/master/config/initializers/devise.rb) | ✅ Implemented | +| **Session History** | Login Audit Trail | [`db/migrate/..._create_session_histories.rb`](https://github.com/mitre/vulcan/blob/master/db/migrate/) | ✅ Implemented | +| **Logout Message** | Confirmation (AC-12) | [`app/controllers/sessions_controller.rb`](https://github.com/mitre/vulcan/blob/master/app/controllers/sessions_controller.rb) | ✅ Implemented | +| **Paranoid Mode** | Account Enumeration Prevention | [`config/initializers/devise.rb`](https://github.com/mitre/vulcan/blob/master/config/initializers/devise.rb) | ✅ Implemented | +| **Cookie Security** | Secure + HttpOnly + SameSite | [`config/initializers/session_store.rb`](https://github.com/mitre/vulcan/blob/master/config/initializers/session_store.rb)
    [`config/initializers/devise.rb`](https://github.com/mitre/vulcan/blob/master/config/initializers/devise.rb) | ✅ Implemented | ### Configuration Clarifications @@ -606,7 +636,6 @@ The following improvements are tracked as GitHub issues: |----------|-------|-------------|--------| | **High** | [#685](https://github.com/mitre/vulcan/issues/685) | Change default session timeout to 10 minutes | v2.3.0 | | **High** | [#635](https://github.com/mitre/vulcan/issues/635) | Add logout confirmation message | v2.3.0 | -| **Medium** | [#634](https://github.com/mitre/vulcan/issues/634) | Implement per-user session limits | v2.3.0 | | **Medium** | [#686](https://github.com/mitre/vulcan/issues/686) | Document CSRF protection explicitly | v2.3.0 | These improvements will be addressed as part of the Vue 3 migration and Turbolinks removal work in v2.3.0. diff --git a/docs/security/security-controls.md b/docs/security/security-controls.md index 2dbbdc0af..73cde168a 100644 --- a/docs/security/security-controls.md +++ b/docs/security/security-controls.md @@ -22,26 +22,26 @@ This document provides Vulcan's responses to the Application Security and Develo |AC-02 (04)|The application must automatically audit account enabling actions.|Configure the application to write a log entry when a user account is enabled. At a minimum, ensure account name, date and time of the event are recorded.|Achieved by integrating your organization's existing external account and authentication mechanisms for user access. Local accounts should only be used for administration and troubleshooting.||10/11/2024|V-222421| |AC-02 (04)|The application must notify System Administrators and Information System Security Officers of account enabling actions.|Configure the application to notify the system administrator and the ISSO when application accounts are enabled.|Achieved by integrating your organization's existing external account and authentication mechanisms for user access. Local accounts should only be used for administration and troubleshooting.||10/11/2024|V-222422| |AC-02 (10)|Shared/group account credentials must be terminated when members leave the group.|Create a procedure for deleting either member accounts or the entire group account when members leave the group.|Achieved by integrating your organization's existing external account and authentication mechanisms for user access. Local accounts should only be used for administration and troubleshooting.||10/11/2024|V-222408| -|AC-03|The application must enforce approved authorizations for logical access to information and system resources in accordance with applicable access control policies.|Design or configure the application to enforce access to application resources.|Vulcan Server enforces role-based access control for user, administrator, and other application roles.||10/11/2024|V-222425| -|AC-03 (04)|The application must enforce organization-defined discretionary access control policies over defined subjects and objects.|Design and configure the application to enforce discretionary access control policies.|Vulcan Server enforces role-based access control for user, administrator, and other application roles.||10/11/2024|V-222426| +|AC-03|The application must enforce approved authorizations for logical access to information and system resources in accordance with applicable access control policies.|Design or configure the application to enforce access to application resources.|Vulcan enforces deny-by-default authorization via `before_action` callbacks on every controller action. A safety net spec (`spec/requests/authorization_coverage_spec.rb`) introspects the route table and verifies every routed action has an authorization check. Role-based access control covers user, administrator, and project membership roles.||02/20/2026|V-222425| +|AC-03 (04)|The application must enforce organization-defined discretionary access control policies over defined subjects and objects.|Design and configure the application to enforce discretionary access control policies.|Vulcan enforces project-level access control via Membership records with role-based permissions. Per-section field locking allows project leads to lock specific rule fields from editing by other members. Admin-only actions are protected by `authorize_admin` callbacks.||02/20/2026|V-222426| |AC-04|The application must enforce approved authorizations for controlling the flow of information within the system based on organization-defined information flow control policies.|Configure the application to enforce data flow control in accordance with data flow control policies.|Vulcan Server enforces role-based access control on its GUI.||10/11/2024|V-222427| |AC-04|The application must enforce approved authorizations for controlling the flow of information between interconnected systems based on organization-defined information flow control policies.|Configure the application to enforce data flow control in accordance with data flow control policies.|Vulcan Server enforces role-based access control on its GUI.||10/11/2024|V-222428| |AC-06 (04)|Application web servers must be on a separate network segment from the application and database servers if it is a tiered application operating in the organization DMZ.|Separate web server from other application tiers and place it on a separate network segment apart from the application and database servers in accordance with organization DMZ data access controls requirements.|Vulcan Server can be deployed with application logic on a separate server/network from the database server/network.||10/11/2024|V-222620| |AC-06 (08)|The application must execute without excessive account permissions.|Configure the application accounts with minimalist privileges. Do not allow the application to operate with admin credentials.|The user deploying Vulcan Server can choose both the account under which the application logic runs under, as well as the account used to access the backend database. Neither account needs to be nor should have full access rights to the OS or Database, respectively.||10/11/2024|V-222430| |AC-06 (09)|The application must audit the execution of privileged functions.|Configure the application to write log entries when privileged functions are executed. At a minimum, ensure the specific action taken, date and time of event are recorded.|Vulcan Server audits actions of all users including users with administrative role.||10/11/2024|V-222431| -|AC-06 (10)|The application must prevent non-privileged users from executing privileged functions to include disabling, circumventing, or altering implemented security safeguards/countermeasures.|Modify the application to limit access and prevent the disabling or circumvention of security safeguards.|Vulcan Server enforces role-based access control for user, administrator, and other application roles.||10/11/2024|V-222429| +|AC-06 (10)|The application must prevent non-privileged users from executing privileged functions to include disabling, circumventing, or altering implemented security safeguards/countermeasures.|Modify the application to limit access and prevent the disabling or circumvention of security safeguards.|Vulcan enforces deny-by-default authorization on every controller action. Admin-only functions (user management, account lockout, system configuration) are protected by `authorize_admin` callbacks. Last-admin protection prevents demoting or deleting the only administrator.||02/20/2026|V-222429| |AC-07 a|The application must enforce the limit of three consecutive invalid logon attempts by a user during a 15 minute time period.|Configure the application to enforce an account lock after 3 failed logon attempts occurring within a 15-minute window.|Vulcan Server implements Devise `:lockable` with configurable settings via `VULCAN_LOCKOUT_*` environment variables. Defaults: 3 attempts, 15-minute auto-unlock, strategy `both` (email + time). Locked users see "Your account is locked" on login. Administrators can unlock accounts from the Users page.||02/19/2026|V-222432| |AC-07 b|The application administrator must follow an approved process to unlock locked user accounts.|Create a standard approved process for unlocking locked application accounts which includes validating user identity prior to unlocking the account. Use that process when unlocking application user accounts.|Vulcan Server provides an admin unlock endpoint (`POST /users/:id/unlock`) accessible from the Users page. Locked users display a "Locked" badge. Administrators click edit, verify the user identity, and click the Unlock button. Accounts also auto-unlock after the configured time period (default: 15 minutes).||02/19/2026|V-222433| |AC-08 a|The application must display an approved organization banner before granting access to the application.|Configure the application to present the approved organization banner prior to granting access to the application.|Vulcan Server provides three mechanisms: (1) `VULCAN_WELCOME_TEXT` for login page text, (2) a configurable classification banner (`VULCAN_BANNER_*`) displayed on every page, and (3) a consent modal (`VULCAN_CONSENT_*`) that blocks access until the user acknowledges terms of use.||02/19/2026|V-222434| |AC-08 b|The application must retain the approved organization banner on the screen until users acknowledge the usage conditions and take explicit actions to log on for further access.|Configure the application to retain the approved organization banner until the user accepts the usage conditions prior to granting access to the application.|Vulcan Server provides a consent modal (`VULCAN_CONSENT_*`) that blocks access until the user explicitly acknowledges the usage conditions. The classification banner persists on every page during the session. The welcome text is shown on the login page.||02/19/2026|V-222435| |AC-08 c 1;AC-08 c 2;AC-08 c 3|The publicly accessible application must display an approved organization banner before granting access to the application.|Configure the application to present the approved organization banner prior to granting access to the application.|Vulcan Server provides a classification banner and consent modal configurable via environment variables. The banner is server-rendered (works without JavaScript) and the consent modal requires explicit acknowledgement.||02/19/2026|V-222436| |AC-09|The application must display the time and date of the users last successful logon.|Design and configure the application to display the date and time when the user was last successfully granted access to the application.|Achieved by integrating your organization's existing external account and authentication mechanisms for user access. Local accounts should only be used for administration and troubleshooting.||10/11/2024|V-222437| -|AC-10|The application must provide a capability to limit the number of logon sessions per user.|Design and configure the application to specify the number of logon sessions that are allowed per user.|At this time, Vulcan Server does not limit sessions per user. Addressing under https://github.com/mitre/vulcan/issues/634 |Yes|10/11/2024|V-222387| +|AC-10|The application must provide a capability to limit the number of logon sessions per user.|Design and configure the application to specify the number of logon sessions that are allowed per user.|Vulcan enforces configurable concurrent session limits via devise-security `:session_limitable` and `:session_traceable` with ActiveRecord session store. Each login creates a `SessionHistory` record with token, IP, and user agent. When a user exceeds `max_active_sessions` (default: 1, configurable via `VULCAN_MAX_CONCURRENT_SESSIONS`), the oldest session is evicted. Configurable via `VULCAN_SESSION_LIMITS_ENABLED` (default: true). Additional mitigations: session timeout (`VULCAN_SESSION_TIMEOUT`), account lockout after 3 failed attempts (Devise `:lockable`), and rack-attack rate limiting.||02/22/2026|V-222387| |AC-12|The application must clear temporary storage and cookies when the session is terminated.|Design and configure the application to clear sensitive data from cookies and local storage when the user logs out of the application.|Vulcan Server only stores one sensitive piece of data in the Local Storage. When the user clicks 'Log Off' it is cleared from local storage.||10/11/2024|V-222388| |AC-12|The application must automatically terminate the non-privileged user session and log off non-privileged users after a 15 minute idle time period has elapsed.|Design and configure the application to terminate the non-privileged users session after 15 minutes of inactivity.|Vulcan Environment Variable 'VULCAN_SESSION_TIMEOUT' should be set to `15m` (or `900` seconds) for non-privileged users and `10m` (or `600` seconds) for admin users to limit user sessions per DoD requirements. Vulcan accepts explicit suffixes (e.g., `15m`, `10m`, `900s`) or plain seconds (e.g., `900`).||10/11/2024|V-222389| |AC-12|The application must automatically terminate the admin user session and log off admin users after a 10 minute idle time period is exceeded.|Design and configure the application to terminate the admin users session after 10 minutes of inactivity.|Vulcan Environment Variable 'VULCAN_SESSION_TIMEOUT' should be set to `15m` (or `900` seconds) for non-privileged users and `10m` (or `600` seconds) for admin users to limit user sessions per DoD requirements. Vulcan accepts explicit suffixes (e.g., `15m`, `10m`, `900s`) or plain seconds (e.g., `900`).||10/11/2024|V-222390| |AC-12 (01)|Applications requiring user access authentication must provide a logoff capability for user initiated communication session.|Design and configure the application to provide all users with the capability to manually terminate their application session.|Vulcan provides a Log Out button in the web page for clients that will invalidate their current session.||10/11/2024|V-222391| -|AC-12 (02)|The application must display an explicit logoff message to users indicating the reliable termination of authenticated communications sessions.|Design and configure the application to provide an explicit logoff message to users indicating a successful logoff has occurred upon user session termination.|At this time, Vulcan Server does not provide a logoff confirmation message to the user after logoff. Addressing under https://github.com/mitre/vulcan/issues/635|Yes|10/11/2024|V-222392| +|AC-12 (02)|The application must display an explicit logoff message to users indicating the reliable termination of authenticated communications sessions.|Design and configure the application to provide an explicit logoff message to users indicating a successful logoff has occurred upon user session termination.|Vulcan displays "Signed out successfully." via the Toaster notification component after logout. Devise sets flash[:notice] with the confirmation message, which is passed as a Vue prop to the Toaster component present on every page via the application layout.||02/20/2026|V-222392| |AC-16 a|The application must associate organization-defined types of security attributes having organization-defined security attribute values with information in storage.|Design and configure the application to assign data marking and ensure the marking is retained when the data is stored.|Vulcan provides a configurable classification banner (`VULCAN_BANNER_*`) displayed on every page to mark the system's sensitivity level. The STIG/SRG data format itself does not include per-record classification markings.||02/19/2026|V-222393| |AC-16 a|The application must associate organization-defined types of security attributes having organization-defined security attribute values with information in process.|Design and configure the application to retain the data marking when processing data.|Vulcan displays a classification banner on every page during data processing. The STIG/SRG data format does not include per-record classification markings.||02/19/2026|V-222394| |AC-16 a|The application must associate organization-defined types of security attributes having organization-defined security attribute values with information in transmission.|Design and configure the application to retain the data marking when transmitting data.|Vulcan displays a classification banner on every page. Exported data (XCCDF, CSV, InSpec) does not embed classification markings; organizations should apply markings per their data handling procedures.||02/19/2026|V-222395| @@ -53,48 +53,48 @@ This document provides Vulcan's responses to the Application Security and Develo |AU-03 a|The application must log application shutdown events.|Configure the application or application server to record application shutdown events in the event logs.|Vulcan Server logs application shutdown events.||10/11/2024|V-222469| |AU-03 a|The application must log destination IP addresses.|Configure the application to record the destination IP address of the remote system.|Vulcan Server logs all connections by user and location (IP).||10/11/2024|V-222470| |AU-03 a|The application must log user actions involving access to data.|Identify the specific data elements requiring protection and audit access to the data.|Vulcan Server implements parameter logging that helps identify what specific actions a user is making, including what results a user would have access to and how those results are modified.||10/11/2024|V-222471| -|AU-03 a|The application must log user actions involving changes to data.|Configure the application to log all changes to application data.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222472| +|AU-03 a|The application must log user actions involving changes to data.|Configure the application to log all changes to application data.|Vulcan tracks data changes via the audited gem, which records before/after values on core models (User, Project, Component, Rule) with user ID, timestamp, request UUID, remote address, and changed fields. Audit records are append-only with max_audits: 1000 per record.||02/20/2026|V-222472| |AU-03 b|The application must produce audit records containing information to establish when (date and time) the events occurred.|Configure the application or application server to include the date and the time of the event in the audit logs.|Vulcan Server logs date/time for application activities.||10/11/2024|V-222473| |AU-03 c|The application must produce audit records containing enough information to establish which component, feature or function of the application triggered the audit event.|Configure the application to log which component, feature or functionality of the application triggered the event.|All of the URI paths logged from user requests map to an application module in Vulcan.||10/11/2024|V-222474| -|AU-03 d|When using centralized logging; the application must include a unique identifier in order to distinguish itself from other application logs.|Configure the application logs or the centralized log storage facility so the application name and the hosts hosting the application are uniquely identified in the logs.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222475| +|AU-03 d|When using centralized logging; the application must include a unique identifier in order to distinguish itself from other application logs.|Configure the application logs or the centralized log storage facility so the application name and the hosts hosting the application are uniquely identified in the logs.|Rails tagged logging includes request_id on every log line (config/environments/production.rb). Application name is identifiable via process name and log format. Log output is to stdout per 12-factor app principles.||02/20/2026|V-222475| |AU-03 e|The application must produce audit records that contain information to establish the outcome of the events.|Configure the application to include the outcome of application functions or events.|Vulcan Server logs failures. For example: an unauthorized or malformed request.||10/11/2024|V-222476| |AU-03 f|The application must generate audit records containing information that establishes the identity of any individual or process associated with the event.|Configure the application to log the identity of the user and/or the process associated with the event.|Unique User IDs are logged with each action.||10/11/2024|V-222477| |AU-03 (01)|The application must generate audit records containing the full-text recording of privileged commands or the individual identities of group account users.|Configure the application to log the full text recording of privileged commands or the individual identities of group users.|Vulcan Server logs details for application activities.||10/11/2024|V-222478| -|AU-03 (01)|The application must implement transaction recovery logs when transaction based.|Configure the application database to utilize transactional logging.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222479| -|AU-03 (02)|The application must provide centralized management and configuration of the content to be captured in audit records generated by all application components.|Configure the application to utilize a centralized log management system that provides the capability to configure the content of audit records.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222480| -|AU-04 (01)|The application must off-load audit records onto a different system or media than the system being audited.|Configure the application to off-load audit records onto a different system as per approved schedule.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222481| -|AU-04 (01)|The application must be configured to write application logs to a centralized log repository.|Configure the application to utilize a centralized log repository and ensure the logs are off-loaded from the application system as quickly as possible.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222482| -|AU-05 a|The application must alert the ISSO and SA (at a minimum) in the event of an audit processing failure.|Configure the application to send an alarm in the event the audit system has failed or is failing.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222485| -|AU-05 b|The application must shut down by default upon audit failure (unless availability is an overriding concern).|Configure the application to cease processing if the audit system fails or configure the application to continue logging in a manner that compensates for the audit failure.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222486| -|AU-05 (01)|The application must provide an immediate warning to the SA and ISSO (at a minimum) when allocated audit record storage volume reaches 75% of repository maximum audit record storage capacity.|Configure the application to send an immediate alarm to the application admin/SA and the ISSO when the allocated log storage capacity exceeds 75% of usage or exceeds the capacity value the SA and ISSO have determined will provide adequate time to plan for capacity expansion.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222483| -|AU-05 (02)|Applications categorized as having a moderate or high impact must provide an immediate real-time alert to the SA and ISSO (at a minimum) for all audit failure events.|Configure the log alerts to send an alarm when the audit system is in danger of failing or has failed. Configure the log alerts to be immediately sent to the application admin/SA and ISSO.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222484| +|AU-03 (01)|The application must implement transaction recovery logs when transaction based.|Configure the application database to utilize transactional logging.|PostgreSQL Write-Ahead Log (WAL) provides transaction recovery. Application-level audit trail via audited gem with max_audits: 1000 per record, recording user, timestamp, and changed fields.||02/20/2026|V-222479| +|AU-03 (02)|The application must provide centralized management and configuration of the content to be captured in audit records generated by all application components.|Configure the application to utilize a centralized log management system that provides the capability to configure the content of audit records.|Vulcan's audit configuration is centralized in model declarations (audited only:/audited except:) and config/initializers/audited.rb. Log output format and destination are managed via stdout per 12-factor principles; the deploying organization's infrastructure handles centralized collection.||02/20/2026|V-222480| +|AU-04 (01)|The application must off-load audit records onto a different system or media than the system being audited.|Configure the application to off-load audit records onto a different system as per approved schedule.|Vulcan logs to stdout per 12-factor app principles. Container log drivers (Docker, k8s) capture stdout and forward to the organization's centralized log aggregator (ELK, Splunk, CloudWatch, Fluentd).||02/20/2026|V-222481| +|AU-04 (01)|The application must be configured to write application logs to a centralized log repository.|Configure the application to utilize a centralized log repository and ensure the logs are off-loaded from the application system as quickly as possible.|Vulcan logs to stdout per 12-factor app principles. Container log drivers (Docker, k8s) capture stdout and forward to the organization's centralized log aggregator (ELK, Splunk, CloudWatch, Fluentd).||02/20/2026|V-222482| +|AU-05 a|The application must alert the ISSO and SA (at a minimum) in the event of an audit processing failure.|Configure the application to send an alarm in the event the audit system has failed or is failing.|Vulcan logs to stdout per 12-factor app principles. Stdout cannot independently fail. Container orchestrators (k8s, ECS) monitor process health and restart on failure. Audit failure alerting is the responsibility of the deploying organization's monitoring infrastructure.||02/20/2026|V-222485| +|AU-05 b|The application must shut down by default upon audit failure (unless availability is an overriding concern).|Configure the application to cease processing if the audit system fails or configure the application to continue logging in a manner that compensates for the audit failure.|Vulcan logs to stdout per 12-factor app principles. Stdout cannot independently fail. Container orchestrators (k8s, ECS) monitor process health and restart on failure. Audit failure alerting is the responsibility of the deploying organization's monitoring infrastructure.||02/20/2026|V-222486| +|AU-05 (01)|The application must provide an immediate warning to the SA and ISSO (at a minimum) when allocated audit record storage volume reaches 75% of repository maximum audit record storage capacity.|Configure the application to send an immediate alarm to the application admin/SA and the ISSO when the allocated log storage capacity exceeds 75% of usage or exceeds the capacity value the SA and ISSO have determined will provide adequate time to plan for capacity expansion.|Vulcan logs to stdout per 12-factor app principles. Stdout is ephemeral with no local storage to fill. The deploying organization's log aggregator monitors its own storage capacity.||02/20/2026|V-222483| +|AU-05 (02)|Applications categorized as having a moderate or high impact must provide an immediate real-time alert to the SA and ISSO (at a minimum) for all audit failure events.|Configure the log alerts to send an alarm when the audit system is in danger of failing or has failed. Configure the log alerts to be immediately sent to the application admin/SA and ISSO.|Vulcan logs to stdout per 12-factor app principles. Stdout cannot independently fail. Container orchestrators (k8s, ECS) monitor process health and restart on failure. Audit failure alerting is the responsibility of the deploying organization's monitoring infrastructure.||02/20/2026|V-222484| |AU-06 b|The ISSO must report all suspected violations of security policies in accordance with organization procedures.|Create and maintain a policy to report security violations.|Achieved by leveraging or adapting your organization's policies for reporting security violations.||10/11/2024|V-222623| -|AU-06 (04)|The application must provide the capability to centrally review and analyze audit records from multiple components within the system.|Configure the application so all of the applications logs are available for review from one centralized location.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222487| -|AU-06 (10)|The ISSO must review audit trails periodically based on system documentation recommendations or immediately upon system security events.|Establish a scheduled process for reviewing logs. Maintain a log or records of dates and times audit logs are reviewed.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222622| -|AU-07 a|The application must provide an audit reduction capability that supports on-demand audit review and analysis.|Configure the application to log to a centralized auditing capability that provides on-demand reports based on the filtered audit event data or design or configure the application to meet the requirement.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222490| -|AU-07 a|The application must provide an audit reduction capability that supports on-demand reporting requirements.|Configure the application to generate soft copy, hard copy and/or screen-based reports based on the selected filtered event data.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222489| -|AU-07 a|The application must provide an audit reduction capability that supports after-the-fact investigations of security incidents.|Configure the application to provide an audit reduction capability that supports forensic investigations.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222491| -|AU-07 a|The application must provide a report generation capability that supports on-demand audit review and analysis.|Design or configure the application to provide an immediate audit review capability or utilize a centralized utility designed for the purpose of on-demand log management and reporting.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222492| -|AU-07 a|The application must provide a report generation capability that supports on-demand reporting requirements.|Design or configure the application to provide an on-demand report generation capability or utilize a centralized utility designed for the purpose of on-demand log management and reporting.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222493| -|AU-07 a|The application must provide a report generation capability that supports after-the-fact investigations of security incidents.|Design or configure the application to provide after-the-fact report generation capability or utilize a centralized utility designed for the purpose of log management and reporting.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222494| -|AU-07 b|The application must provide an audit reduction capability that does not alter original content or time ordering of audit records.|Configure the application to not alter original log content or time ordering of audit records.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222495| -|AU-07 b|The application must provide a report generation capability that does not alter original content or time ordering of audit records.|Configure and design the application to not modify source logs when filtering events.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222496| -|AU-07 (01)|The application must provide the capability to filter audit records for events of interest based upon organization-defined criteria.|Configure the application filters to search event logs based on defined criteria.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222488| +|AU-06 (04)|The application must provide the capability to centrally review and analyze audit records from multiple components within the system.|Configure the application so all of the applications logs are available for review from one centralized location.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222487| +|AU-06 (10)|The ISSO must review audit trails periodically based on system documentation recommendations or immediately upon system security events.|Establish a scheduled process for reviewing logs. Maintain a log or records of dates and times audit logs are reviewed.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222622| +|AU-07 a|The application must provide an audit reduction capability that supports on-demand audit review and analysis.|Configure the application to log to a centralized auditing capability that provides on-demand reports based on the filtered audit event data or design or configure the application to meet the requirement.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222490| +|AU-07 a|The application must provide an audit reduction capability that supports on-demand reporting requirements.|Configure the application to generate soft copy, hard copy and/or screen-based reports based on the selected filtered event data.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222489| +|AU-07 a|The application must provide an audit reduction capability that supports after-the-fact investigations of security incidents.|Configure the application to provide an audit reduction capability that supports forensic investigations.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222491| +|AU-07 a|The application must provide a report generation capability that supports on-demand audit review and analysis.|Design or configure the application to provide an immediate audit review capability or utilize a centralized utility designed for the purpose of on-demand log management and reporting.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222492| +|AU-07 a|The application must provide a report generation capability that supports on-demand reporting requirements.|Design or configure the application to provide an on-demand report generation capability or utilize a centralized utility designed for the purpose of on-demand log management and reporting.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222493| +|AU-07 a|The application must provide a report generation capability that supports after-the-fact investigations of security incidents.|Design or configure the application to provide after-the-fact report generation capability or utilize a centralized utility designed for the purpose of log management and reporting.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222494| +|AU-07 b|The application must provide an audit reduction capability that does not alter original content or time ordering of audit records.|Configure the application to not alter original log content or time ordering of audit records.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222495| +|AU-07 b|The application must provide a report generation capability that does not alter original content or time ordering of audit records.|Configure and design the application to not modify source logs when filtering events.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222496| +|AU-07 (01)|The application must provide the capability to filter audit records for events of interest based upon organization-defined criteria.|Configure the application filters to search event logs based on defined criteria.|Vulcan logs to stdout per 12-factor app principles. Audit reduction, filtering, report generation, and periodic review are the responsibility of the deploying organization's log management system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222488| |AU-08 a|The applications must use internal system clocks to generate time stamps for audit records.|Configure the application to use the hosting systems internal clock for audit record generation.|This requirement is inherited from underlying operating system time functions.||10/11/2024|V-222497| |AU-08 b|The application must record time stamps for audit records that meet a granularity of one second for a minimum degree of precision.|Configure the application to leverage the underlying operating system as the time source when recording time stamps or design the application to ensure granularity of 1 second as the minimum degree of precision.|This requirement is inherited from underlying operating system time functions.||10/11/2024|V-222499| |AU-08 b|The application must record time stamps for audit records that can be mapped to Coordinated Universal Time (UTC) or Greenwich Mean Time (GMT).|Configure the application to use the underlying system clock that maps to relevant UTC or GMT timezone.|This requirement is inherited from underlying operating system time functions.||10/11/2024|V-222498| -|AU-09|The application must protect audit tools from unauthorized modification.|Configure the application to protect audit tools from unauthorized modifications. Limit users to roles that are assigned the rights to edit or update audit tools and establish file permissions that control access to the audit tools and audit tool capabilities and configuration settings.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222504| -|AU-09|The application must protect audit tools from unauthorized deletion.|Configure the application to protect audit tools from unauthorized deletions. Limit users to roles that are assigned the rights to edit or delete audit tools and establish file permissions that control access to the audit tools and audit tool capabilities and configuration settings.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222505| -|AU-09 a|The application must protect audit information from any type of unauthorized read access.|Configure the application to protect audit data from unauthorized access. Limit users to roles that are assigned the rights to view, edit or copy audit data, and establish permissions that control access to the audit logs and audit configuration settings.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222500| -|AU-09 a|The application must protect audit information from unauthorized modification.|Configure the application to protect audit data from unauthorized modification and changes. Limit users to roles that are assigned the rights to edit audit data and establish permissions that control access to the audit logs and audit configuration settings.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222501| -|AU-09 a|The application must protect audit information from unauthorized deletion.|Configure the application to protect audit data from unauthorized deletion. Limit users to roles that are assigned the rights to delete audit data and establish permissions that control access to the audit logs and audit configuration settings.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222502| -|AU-09 a|The application must protect audit tools from unauthorized access.|Configure the application to protect audit data from unauthorized access. Limit users to roles that are assigned the rights to view, edit or copy audit data, and establish file permissions that control access to the audit tools and audit tool capabilities and configuration settings.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222503| -|AU-09 (02)|The application must back up audit records at least every seven days onto a different system or system component than the system or component being audited.|Configure application backup settings to backup application audit logs every 7 days.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222506| -|AU-09 (03)|The application must use cryptographic mechanisms to protect the integrity of audit information.|Configure the application to create an integrity check consisting of a cryptographic hash or one-way digest that can be used to establish the integrity when storing log files.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222507| -|AU-09 (03)|Application audit tools must be cryptographically hashed.|Cryptographically hash the audit tool files used by the application. Store and protect the generated hash values for future reference.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222508| -|AU-09 (03)|The integrity of the audit tools must be validated by checking the files for changes in the cryptographic hash value.|Establish a process to periodically check the audit tool cryptographic hashes to ensure the audit tools have not been tampered with.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222509| -|AU-10|The application must protect against an individual (or process acting on behalf of an individual) falsely denying having performed organization-defined actions to be covered by non-repudiation.|Configure the application to provide users with a non-repudiation function in the form of digital signatures when it is required by the organization or by the application design and architecture.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222438| -|AU-11|The ISSO must ensure application audit trails are retained for at least 1 year for applications without SAMI data, and 5 years for applications including SAMI data.|Retain application audit log files for one year and five years for sources and methods intelligence data.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222621| +|AU-09|The application must protect audit tools from unauthorized modification.|Configure the application to protect audit tools from unauthorized modifications. Limit users to roles that are assigned the rights to edit or update audit tools and establish file permissions that control access to the audit tools and audit tool capabilities and configuration settings.|Vulcan logs to stdout per 12-factor app principles. Stdout logs are write-once from the application perspective. Access control, encryption at rest, and deletion protection for audit records are the responsibility of the deploying organization's log management infrastructure.||02/20/2026|V-222504| +|AU-09|The application must protect audit tools from unauthorized deletion.|Configure the application to protect audit tools from unauthorized deletions. Limit users to roles that are assigned the rights to edit or delete audit tools and establish file permissions that control access to the audit tools and audit tool capabilities and configuration settings.|Vulcan logs to stdout per 12-factor app principles. Stdout logs are write-once from the application perspective. Access control, encryption at rest, and deletion protection for audit records are the responsibility of the deploying organization's log management infrastructure.||02/20/2026|V-222505| +|AU-09 a|The application must protect audit information from any type of unauthorized read access.|Configure the application to protect audit data from unauthorized access. Limit users to roles that are assigned the rights to view, edit or copy audit data, and establish permissions that control access to the audit logs and audit configuration settings.|Vulcan logs to stdout per 12-factor app principles. Stdout logs are write-once from the application perspective. Access control, encryption at rest, and deletion protection for audit records are the responsibility of the deploying organization's log management infrastructure.||02/20/2026|V-222500| +|AU-09 a|The application must protect audit information from unauthorized modification.|Configure the application to protect audit data from unauthorized modification and changes. Limit users to roles that are assigned the rights to edit audit data and establish permissions that control access to the audit logs and audit configuration settings.|Vulcan logs to stdout per 12-factor app principles. Stdout logs are write-once from the application perspective. Access control, encryption at rest, and deletion protection for audit records are the responsibility of the deploying organization's log management infrastructure.||02/20/2026|V-222501| +|AU-09 a|The application must protect audit information from unauthorized deletion.|Configure the application to protect audit data from unauthorized deletion. Limit users to roles that are assigned the rights to delete audit data and establish permissions that control access to the audit logs and audit configuration settings.|Vulcan logs to stdout per 12-factor app principles. Stdout logs are write-once from the application perspective. Access control, encryption at rest, and deletion protection for audit records are the responsibility of the deploying organization's log management infrastructure.||02/20/2026|V-222502| +|AU-09 a|The application must protect audit tools from unauthorized access.|Configure the application to protect audit data from unauthorized access. Limit users to roles that are assigned the rights to view, edit or copy audit data, and establish file permissions that control access to the audit tools and audit tool capabilities and configuration settings.|Vulcan logs to stdout per 12-factor app principles. Stdout logs are write-once from the application perspective. Access control, encryption at rest, and deletion protection for audit records are the responsibility of the deploying organization's log management infrastructure.||02/20/2026|V-222503| +|AU-09 (02)|The application must back up audit records at least every seven days onto a different system or system component than the system or component being audited.|Configure application backup settings to backup application audit logs every 7 days.|Vulcan logs to stdout per 12-factor app principles. Log retention, backup frequency, and off-site storage are the responsibility of the deploying organization's log management policy.||02/20/2026|V-222506| +|AU-09 (03)|The application must use cryptographic mechanisms to protect the integrity of audit information.|Configure the application to create an integrity check consisting of a cryptographic hash or one-way digest that can be used to establish the integrity when storing log files.|Vulcan logs to stdout per 12-factor app principles. Application audit code (audited gem) is version-controlled in Git with signed commits. Cryptographic integrity of stored logs and log transport encryption (TLS) are the responsibility of the deploying organization's log infrastructure.||02/20/2026|V-222507| +|AU-09 (03)|Application audit tools must be cryptographically hashed.|Cryptographically hash the audit tool files used by the application. Store and protect the generated hash values for future reference.|Vulcan logs to stdout per 12-factor app principles. Application audit code (audited gem) is version-controlled in Git with signed commits. Cryptographic integrity of stored logs and log transport encryption (TLS) are the responsibility of the deploying organization's log infrastructure.||02/20/2026|V-222508| +|AU-09 (03)|The integrity of the audit tools must be validated by checking the files for changes in the cryptographic hash value.|Establish a process to periodically check the audit tool cryptographic hashes to ensure the audit tools have not been tampered with.|Vulcan logs to stdout per 12-factor app principles. Application audit code (audited gem) is version-controlled in Git with signed commits. Cryptographic integrity of stored logs and log transport encryption (TLS) are the responsibility of the deploying organization's log infrastructure.||02/20/2026|V-222509| +|AU-10|The application must protect against an individual (or process acting on behalf of an individual) falsely denying having performed organization-defined actions to be covered by non-repudiation.|Configure the application to provide users with a non-repudiation function in the form of digital signatures when it is required by the organization or by the application design and architecture.|The audited gem records user_id, username, request_uuid, remote_address, and created_at for every auditable action. Audit records are immutable (append-only). Digital signatures for log integrity are the responsibility of the deploying organization's log management system.||02/20/2026|V-222438| +|AU-11|The ISSO must ensure application audit trails are retained for at least 1 year for applications without SAMI data, and 5 years for applications including SAMI data.|Retain application audit log files for one year and five years for sources and methods intelligence data.|Vulcan logs to stdout per 12-factor app principles. Log retention periods (1 year standard, 5 years for SAMI data) are the responsibility of the deploying organization's log retention policy.||02/20/2026|V-222621| |AU-12 a|The application must provide audit record generation capability for the creation of session IDs.|Enable session ID creation event auditing.|Vulcan Server backend logic records session token creation.||10/11/2024|V-222441| |AU-12 a|The application must provide audit record generation capability for the destruction of session IDs.|Enable session ID destruction event auditing.|Vulcan Server backend logic records session token destruction/change.||10/11/2024|V-222442| |AU-12 a|The application must provide audit record generation capability for the renewal of session IDs.|Design or reconfigure the application to log session renewal events on those application events that provide changes in the users privileges or permissions to the application.|Vulcan Server backend logic records session token creation.||10/11/2024|V-222443| @@ -123,7 +123,7 @@ This document provides Vulcan's responses to the Application Security and Develo |AU-12 c|The application must generate audit records for all direct access to the underlying hosting operating system.|Configure the application to log all direct access to the underlying hosting operating system.|Vulcan does not provide direct access to the underlying operating system, hence this auditing requirement is not applicable.||10/11/2024|V-222466| |AU-12 c|The application must generate audit records for all account creations, modifications, disabling, and termination events.|Configure the application to log user account creation, modification, disabling, and termination events.|This is implemented for local accounts. For integrated external authentication mechanisms, account management is an external process separate from Vulcan.||10/11/2024|V-222467| |AU-12 c|The application must generate audit records when concurrent logons from different workstations occur.|Configure the application to log concurrent logons from different workstations.|Vulcan Server logs all connections by user and location.||10/11/2024|V-222672| -|AU-12 (01)|For applications providing audit record aggregation, the application must compile audit records from organization-defined information system components into a system-wide audit trail that is time-correlated with an organization-defined level of tolerance for the relationship between time stamps of individual records in the audit trail.|Configure the application to correlate time stamps when aggregating audit records.|Vulcan application auditing goes to 'standard out'. The Vulcan postgreSQL database also records transactions, and can be enhanced by the organization using the optional pgaudit module. The organization can choose how to locally store and copy this data to the organization's centralized logging system to retain, protect, search, report and review user and application account activities.||10/11/2024|V-222439| +|AU-12 (01)|For applications providing audit record aggregation, the application must compile audit records from organization-defined information system components into a system-wide audit trail that is time-correlated with an organization-defined level of tolerance for the relationship between time stamps of individual records in the audit trail.|Configure the application to correlate time stamps when aggregating audit records.|Rails tagged logging with request_id enables time-correlated tracing across log lines within a single request. Cross-service correlation requires the deploying organization's log aggregation system (e.g., ELK, Splunk, CloudWatch).||02/20/2026|V-222439| |AU-14 (01)|The application must initiate session auditing upon startup.|Configure the application to begin logging application events as soon as the application starts up.|System Start-Up logging is provided as an inheritable control via SLA with the Source Code Push. The application also initiate logging upon startup and logs to standard out.||10/11/2024|V-222468| |CA-02 (02)|The ISSO must ensure active vulnerability testing is performed.|Perform active vulnerability and fuzz testing of the application. Verify the vulnerability scanning tool is configured to test all application components and functionality. Address discovered vulnerabilities.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning. https://github.com/mitre/Vulcan/security||10/11/2024|V-222624| |CM-04 (02)|Execution flow diagrams and design documents must be created to show how deadlock and recursion issues in web services are being mitigated.|Develop web services to account for deadlock issues.|Achieved by user providing their own reverse proxy to implement TLS.||10/11/2024|V-222625| @@ -184,8 +184,8 @@ This document provides Vulcan's responses to the Application Security and Develo |IA-05 (02) (b) (01)|The application, when utilizing PKI-based authentication, must validate certificates by constructing a certification path (which includes status information) to an accepted trust anchor.|Design the application to construct a certification path to an accepted trust anchor when using PKI-based authentication.|Vulcan can support any CAC-based or Alt. Token-based authentication method via an appropriate OpenID Connect approach.||10/11/2024|V-222550| |IA-05 (02) (d)|The application, for PKI-based authentication, must implement a local cache of revocation data to support path discovery and validation in case of the inability to access revocation information via the network.|Implement a Certificate Revocation List (CRL) import process and configure the application to check the CRL if OCSP is not available.|Vulcan can support any CAC-based or Alt. Token-based authentication method via an appropriate OpenID Connect approach.||10/11/2024|V-222553| |IA-05 (06)|The application must use encryption to implement key exchange and authenticate endpoints prior to establishing a communication channel for key exchange.|Use encryption for key exchange.|Vulcan Server does not use key exchange, hence this requirement is not applicable.||10/11/2024|V-222641| -|IA-05 (07)|The application must not contain embedded authentication data.|Remove embedded authentication data stored in code, configuration files, scripts, HTML file, or any ASCII files.|No passwords or sensitive data are included in documentation or source code. The server administrator is reminded to secure environment variables and initial passwords designated or assigned upon initial deployment.||10/11/2024|V-222642| -|IA-05 (13)|The application must terminate existing user sessions upon account deletion.|Configure the application to terminate existing sessions of users whose accounts are deleted.|At this time, a deleted user's active session does not immediately automatically terminate.|Yes|10/11/2024|V-222549| +|IA-05 (07)|The application must not contain embedded authentication data.|Remove embedded authentication data stored in code, configuration files, scripts, HTML file, or any ASCII files.|No passwords or sensitive data are included in source code or committed configuration. Secrets (SECRET_KEY_BASE, CIPHER_PASSWORD, CIPHER_SALT, database credentials) are provided via environment variables. Docker deployments use `setup-docker-secrets.sh` to generate secrets at runtime. The admin bootstrap rake task generates compliant random passwords.||02/20/2026|V-222642| +|IA-05 (13)|The application must terminate existing user sessions upon account deletion.|Configure the application to terminate existing sessions of users whose accounts are deleted.|Devise cookie-based sessions store the user ID. When a user is deleted, the session deserialization fails on the next request (User.find returns nil), causing Warden to invalidate the session and redirect to login. Verified by automated test in spec/requests/session_invalidation_spec.rb.||02/20/2026|V-222549| |IA-06|The application must not display passwords/PINs as clear text.|Configure the application to obfuscate passwords and PINs when they are being entered so they cannot be read. Design the application so obfuscated passwords cannot be copied and then pasted as clear text.|Vulcan Server does not display passwords/PINS as clear text.||10/11/2024|V-222554| |IA-07|The application must use mechanisms meeting the requirements of applicable federal laws, Executive Orders, directives, policies, regulations, standards, and guidance for authentication to a cryptographic module.|Use FIPS-approved cryptographic modules.|Vulcan Server does not provide authenticated access to a cryptographic module. The requirement is not applicable.||10/11/2024|V-222555| |IA-08|The application must uniquely identify and authenticate non-organizational users (or processes acting on behalf of non-organizational users).|Configure the application to identify and authenticate all non-organizational users.|Vulcan can support any CAC-based or Alt. Token-based authentication method via an appropriate OpenID Connect approach.||10/11/2024|V-222556| @@ -226,8 +226,8 @@ This document provides Vulcan's responses to the Application Security and Develo |SC-02|The application user interface must be either physically or logically separated from data storage and management interfaces.|Configure the application so user interface to the application and management interface to the application is separated.|Application administrative functions are only available within the graphical interface to application users assigned the admin role. Direct database administration is only available to separate accounts for administrators acting via the operating system level.||10/11/2024|V-222574| |SC-03|The application must isolate security functions from non-security functions.|Implement controls within the application that limits access to security configuration functionality and isolates regular application function from security-oriented function.|The application utilizes application based access control with policies enforced by the application's authorization service and policies written by application developers. These policies mediate access to every function and thus any change, request, or destruction of data.||10/11/2024|V-222590| |SC-04|Applications must prevent unauthorized and unintended information transfer via shared system resources.|¬†Configure or design the application to utilize a security control that will implement a boundary that will prevent unauthorized and unintended information transfer via shared system resources.|Vulcan Server does not share information resources via file sharing protocol, nor does it include configuration settings that provide access to data files on the hard drive.||10/11/2024|V-222592| -|SC-05|Protections against DoS attacks must be implemented.|Implement mitigations from the threat model for DOS attacks.|Threat model documentation and implementation of mitigations is the responsibility of the deploying organization.||10/11/2024|V-222667| -|SC-05 (01)|The application must restrict the ability to launch Denial of Service (DoS) attacks against itself or other information systems.|Design and deploy the application to utilize controls that will prevent the application from being affected by DoS attacks or being used to attack other systems. This includes but is not limited to utilizing throttling techniques for application traffic such as QoS or implementing logic controls within the application code itself that prevents application use that results in network or system capabilities being exceeded.|DoS protection is dependent on the user-configuration of network protections within their organization.||10/11/2024|V-222594| +|SC-05|Protections against DoS attacks must be implemented.|Implement mitigations from the threat model for DOS attacks.|Vulcan implements rack-attack middleware for application-level rate limiting: login throttling (5 attempts/min per IP and per email) and upload throttling (10 uploads/min per IP). Deploying organizations should also configure network-level protections (WAF, reverse proxy rate limiting).||02/20/2026|V-222667| +|SC-05 (01)|The application must restrict the ability to launch Denial of Service (DoS) attacks against itself or other information systems.|Design and deploy the application to utilize controls that will prevent the application from being affected by DoS attacks or being used to attack other systems. This includes but is not limited to utilizing throttling techniques for application traffic such as QoS or implementing logic controls within the application code itself that prevents application use that results in network or system capabilities being exceeded.|Vulcan implements rack-attack middleware (`config/initializers/rack_attack.rb`) with throttling rules for login and upload endpoints. Exceeded thresholds return HTTP 429. Deploying organizations should complement with network-level DoS protections.||02/20/2026|V-222594| |SC-05 (02)|The web service design must include redundancy mechanisms when used with high-availability systems.|Build the application to address issues that are found in a redundant environment and utilize redundancy mechanisms to provide high availability.|As a basic node application and database behind a reverse proxy, Vulcan Server can be deployed for highly available configurations depending on an organizations own preferences.||10/11/2024|V-222595| |SC-07 (13)|Connections between the organization enclave and the Internet or other public or commercial wide area networks must require a DMZ.|Setup a DMZ between organization and public networks.|DMZ configuration is the responsibility of the deploying organization.||10/11/2024|V-222671| |SC-08|The application must protect the confidentiality and integrity of transmitted information.|Configure all of the application systems to require TLS encryption in accordance with data protection requirements.|Achieved by user providing their own reverse proxy to implement TLS.||10/11/2024|V-222596| @@ -237,17 +237,17 @@ This document provides Vulcan's responses to the Application Security and Develo |SC-08 (02)|The application must not store sensitive information in hidden fields.|Design and configure the application to not store sensitive information in hidden fields. |Sensitive information is not stored in hidden fields.||10/11/2024|V-222601| |SC-08 (02)|The application must maintain the confidentiality and integrity of information during reception.|Configure all of the application systems to require TLS encryption.|Achieved by user providing their own reverse proxy to implement TLS.||10/11/2024|V-222599| |SC-10|The application must terminate all network connections associated with a communications session at the end of the session.|Configure or design the application to terminate application network sessions at the end of the session.|HTTP-based - there are no open connections to and from other systems as part of their design and function.||10/11/2024|V-222568| -|SC-13 b|The application must utilize FIPS-validated cryptographic modules when signing application components.|Utilize FIPS-validated algorithms when signing application components.|At this time, it is unclear if all packages created via Github Actions at the primary Vulcan Server repository are signed using FIPS 140-2 validated cryptographic modules. Addressing under https://github.com/mitre/vulcan/issues/636|Yes|10/11/2024|V-222570| -|SC-13 b|The application must utilize FIPS-validated cryptographic modules when generating cryptographic hashes.|Configure the application to use a FIPS-validated hashing algorithm when creating a cryptographic hash.|Vulcan Server uses bcrypt for encryption/hashing, which at this time is NOT a validated FIPS 140-2 library. Addressing under https://github.com/mitre/vulcan/issues/636|Yes|10/11/2024|V-222571| -|SC-13 b|The application must utilize FIPS-validated cryptographic modules when protecting unclassified information that requires cryptographic protection.|Configure the application to use a FIPS-validated cryptographic module.|Vulcan Server uses bcrypt for encryption/hashing, which at this time is NOT a validated FIPS 140-2 library. Addressing under https://github.com/mitre/vulcan/issues/636|Yes|10/11/2024|V-222572| +|SC-13 b|The application must utilize FIPS-validated cryptographic modules when signing application components.|Utilize FIPS-validated algorithms when signing application components.|Vulcan uses PBKDF2-SHA512 via OpenSSL::KDF.pbkdf2_hmac for password hashing (FIPS-approved per NIST SP 800-132). On FIPS-enabled hosts (e.g., RHEL with fips-mode-setup), OpenSSL operations use the FIPS-validated module. GitHub Actions signing uses the platform's default cryptographic libraries.||02/20/2026|V-222570| +|SC-13 b|The application must utilize FIPS-validated cryptographic modules when generating cryptographic hashes.|Configure the application to use a FIPS-validated hashing algorithm when creating a cryptographic hash.|Vulcan uses PBKDF2-SHA512 (NIST SP 800-132) for password hashing via devise-encryptable with a custom OpenSSL-backed encryptor. Existing bcrypt passwords are transparently migrated to PBKDF2-SHA512 on next successful login. On FIPS-enabled hosts, OpenSSL provides FIPS-validated cryptographic operations.||02/20/2026|V-222571| +|SC-13 b|The application must utilize FIPS-validated cryptographic modules when protecting unclassified information that requires cryptographic protection.|Configure the application to use a FIPS-validated cryptographic module.|Vulcan uses PBKDF2-SHA512 (FIPS-approved) for password hashing. All cryptographic operations use Ruby's OpenSSL bindings, which delegate to the system's OpenSSL library. On FIPS-enabled hosts, this uses the FIPS-validated module.||02/20/2026|V-222572| |SC-13 b|The application must implement organization-approved cryptography to protect sensitive organization information.|Configure application to encrypt stored sensitive mission information|Vulcan Server can be configured to encrypt stored classified results data by implementing the correct modules/settings on PostgreSQL and hosting operating system.||10/11/2024|V-254803| |SC-18 (01)|Unsigned Category 1A mobile code must not be used in the application.|Configure the application so Category 1A mobile code is signed.|Vulcan Server does not use mobile code.||10/11/2024|V-222618| |SC-18 (02)|The designer must ensure uncategorized or emerging mobile code is not used in applications.|Remove uncategorized or emerging mobile code from the application or obtain a waiver and risk acceptance to operate.|Vulcan does not contain any uncategorized mobile code.||10/11/2024|V-222665| |SC-23|The application must set the HTTPOnly flag on session cookies.|Configure the application to set the HTTPOnly flag on session cookies.|Vulcan Server inherits the default HTTPOnly flag from the underlying Ruby on Rails security framework.||10/11/2024|V-222575| -|SC-23|The application must set the secure flag on session cookies.|Configure the application to ensure the secure flag is set on session cookies.|Vulcan Server does not currently set the secure flag using the underlying Ruby on Rails security framework. Addressing under https://github.com/mitre/vulcan/issues/641|Yes|10/11/2024|V-222576| +|SC-23|The application must set the secure flag on session cookies.|Configure the application to ensure the secure flag is set on session cookies.|Vulcan sets the secure flag on session cookies automatically when `RAILS_FORCE_SSL=true` (default in production). Rails `force_ssl` enables secure cookies, HSTS headers, and HTTPS redirects. Set `RAILS_FORCE_SSL=false` only for local development without TLS.||02/20/2026|V-222576| |SC-23|The application must not expose session IDs.|Configure the application to protect session IDs from interception or from manipulation.|Vulcan Server protects session IDs from interception or from manipulation using force_ssl in the underlying Ruby on Rails security framework. ||10/11/2024|V-222577| |SC-23 (01)|The application must destroy the session ID value and/or cookie on logoff or browser close.|Configure the application to destroy session ID cookies once the application session has terminated.|Once a User clicks 'Log Off' the Devise Session ID is destroyed||10/11/2024|V-222578| -|SC-23 (03)|The application must use the Federal Information Processing Standard (FIPS) 140-2-validated cryptographic modules and random number generator if the application implements encryption, key exchange, digital signature, and hash functionality.|Configure the application to use FIPS 140-2-validated cryptographic modules when the application implements encryption, key exchange, digital signatures, random number generators, and hash functionality.|Vulcan Server uses bcrypt for encryption/hashing, which at this time is NOT a validated FIPS 140-2 library. Addressing under https://github.com/mitre/vulcan/issues/636|Yes|10/11/2024|V-222583| +|SC-23 (03)|The application must use the Federal Information Processing Standard (FIPS) 140-2-validated cryptographic modules and random number generator if the application implements encryption, key exchange, digital signature, and hash functionality.|Configure the application to use FIPS 140-2-validated cryptographic modules when the application implements encryption, key exchange, digital signatures, random number generators, and hash functionality.|Vulcan uses PBKDF2-SHA512 for password hashing via OpenSSL::KDF.pbkdf2_hmac with configurable iteration count. Session identifiers use SecureRandom (OpenSSL-backed). On FIPS-enabled hosts (e.g., RHEL), all OpenSSL operations use the FIPS-validated module. Random number generation uses OpenSSL's CSPRNG.||02/20/2026|V-222583| |SC-23 (03)|Applications must use system-generated session identifiers that protect against session fixation.|Design the application to generate new session IDs with unique values when authenticating user sessions.|Devise Session IDs are generated for users with a secure random secret generated on a per-user basis.||10/11/2024|V-222579| |SC-23 (03)|Applications must validate session identifiers.|Configure the application to configure user session identifiers.|Vulcan Server uses Devise Session management. Session IDs are cryptographically signed which is validated on every request to the GUI.||10/11/2024|V-222580| |SC-23 (03)|Applications must not use URL embedded session IDs.|Configure the application to transmit session ID information via cookies.|Vulcan Server uses Devise Session management. Session IDs User's: email, role, database primary key, and if they are required to change their password||10/11/2024|V-222581| @@ -268,14 +268,14 @@ This document provides Vulcan's responses to the Application Security and Develo |SI-06 a|The application performing organization-defined security functions must verify correct operation of security functions.|Design the application to verify the correct operation of security functions.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222615| |SI-06 b|The application must perform verification of the correct operation of security functions: upon system startup and/or restart; upon command by a user with privileged access; and/or every 30 days.|Design the application to verify the correct operation of security functions on command and on application startup and restart.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222616| |SI-06 c|The application must notify the ISSO and ISSM of failed security verification tests.|Configure the application to send notices to the ISSO and ISSM indicating the application failed a verification test.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality. ISSOs and ISSMs can sign up for alerts from these automated tests.||10/11/2024|V-222617| -|SI-10|The application must protect from Cross-Site Scripting (XSS) vulnerabilities.|Verify user input is validated and encode or escape user input to prevent embedded script code from executing. Develop your application using a web template system or a web application development framework that provides auto escaping features rather than building your own escape logic.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is secured/patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222602| -|SI-10|The application must protect from Cross-Site Request Forgery (CSRF) vulnerabilities.|Configure the application to use unpredictable challenge tokens and check the HTTP referrer to ensure the request was issued from the site itself. Implement mitigating controls as required such as using web reputation services.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is secured/patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222603| -|SI-10|The application must protect from command injection.|Modify the application so as to escape/sanitize special character input or configure the system to protect against command injection attacks based on application architecture.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is secured/patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222604| -|SI-10|The application must protect from canonical representation vulnerabilities.|A suitable canonical form should be chosen and all user input canonicalized into that form before any authorization decisions are performed. Security checks should be carried out after decoding is completed. Moreover, it is recommended to check that the encoding method chosen is a valid canonical encoding for the symbol it represents.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is secured/patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222605| -|SI-10|The application must validate all input.|Design and configure the application to validate input prior to executing commands.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is secured/patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222606| -|SI-10|The application must not be vulnerable to SQL Injection.|Modify the application and remove SQL injection vulnerabilities.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is secured/patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222607| -|SI-10|The application must not be vulnerable to XML-oriented attacks.|Design the application to utilize components that are not vulnerable to XML attacks. Patch the application components when vulnerabilities are discovered.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is secured/patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222608| -|SI-10 (03)|The application must not be subject to input handling vulnerabilities.|Follow best practice when accepting user input and verify that all input is validated before the application processes the input. Remediate identified vulnerabilities and obtain documented risk acceptance for those issues that cannot be remediated immediately.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is secured/patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222609| +|SI-10|The application must protect from Cross-Site Scripting (XSS) vulnerabilities.|Verify user input is validated and encode or escape user input to prevent embedded script code from executing. Develop your application using a web template system or a web application development framework that provides auto escaping features rather than building your own escape logic.|Vulcan uses Rails HAML templates with automatic HTML escaping and Vue.js with built-in XSS protection (v-text, template interpolation). User-supplied Markdown content is sanitized with DOMPurify before rendering. Continuous automated scanning via GitHub Actions.||02/20/2026|V-222602| +|SI-10|The application must protect from Cross-Site Request Forgery (CSRF) vulnerabilities.|Configure the application to use unpredictable challenge tokens and check the HTTP referrer to ensure the request was issued from the site itself. Implement mitigating controls as required such as using web reputation services.|Vulcan inherits Rails built-in CSRF protection via `protect_from_forgery with: :exception` in ApplicationController. Every form submission includes an authenticity token validated server-side. API requests use Rails UJS for automatic token inclusion.||02/20/2026|V-222603| +|SI-10|The application must protect from command injection.|Modify the application so as to escape/sanitize special character input or configure the system to protect against command injection attacks based on application architecture.|Vulcan does not execute shell commands from user input. All database interactions use ActiveRecord parameterized queries. File operations use Rails built-in methods with path sanitization. Brakeman static analysis runs on every commit to detect command injection risks.||02/20/2026|V-222604| +|SI-10|The application must protect from canonical representation vulnerabilities.|A suitable canonical form should be chosen and all user input canonicalized into that form before any authorization decisions are performed. Security checks should be carried out after decoding is completed. Moreover, it is recommended to check that the encoding method chosen is a valid canonical encoding for the symbol it represents.|Vulcan uses Rails built-in request parsing which handles URL decoding before routing. File upload validation checks content-type against a whitelist after decoding. Email addresses are normalized (stripped, downcased) before comparison.||02/20/2026|V-222605| +|SI-10|The application must validate all input.|Design and configure the application to validate input prior to executing commands.|Vulcan validates input at multiple layers: (1) Upload validation via `UploadValidatable` concern enforces file size limits (50 MB XML, 100 MB ZIP, 50 MB spreadsheet) and content-type whitelists on all upload endpoints. (2) ActiveRecord length validations on Project (name: 255, description: 5000) and Component (name: 255, prefix: 10, title: 500, description: 5000) fields. (3) Strong parameters on all controllers. (4) Rate limiting via rack-attack on login and upload endpoints.||02/20/2026|V-222606| +|SI-10|The application must not be vulnerable to SQL Injection.|Modify the application and remove SQL injection vulnerabilities.|Vulcan uses ActiveRecord parameterized queries exclusively — no raw SQL with user input. All query parameters use `?` placeholders or hash-based `where()` syntax. `sanitize_sql_like()` is used for LIKE queries. Brakeman static analysis runs on every commit to detect SQL injection risks.||02/20/2026|V-222607| +|SI-10|The application must not be vulnerable to XML-oriented attacks.|Design the application to utilize components that are not vulnerable to XML attacks. Patch the application components when vulnerabilities are discovered.|Vulcan prevents XXE (XML External Entity) attacks via: (1) Nokogiri XML parsing uses `NONET` flag (blocks network access during parsing) instead of `NOENT` (which enables entity expansion). (2) HappyMapper XML parsing is patched via `config/initializers/nokogiri_security.rb` to pre-parse with `NONET` and `STRICT` flags before processing. (3) No XML DTD processing or external entity resolution is permitted. 3 dedicated XXE prevention tests verify these protections.||02/20/2026|V-222608| +|SI-10 (03)|The application must not be subject to input handling vulnerabilities.|Follow best practice when accepting user input and verify that all input is validated before the application processes the input. Remediate identified vulnerabilities and obtain documented risk acceptance for those issues that cannot be remediated immediately.|Vulcan implements comprehensive input handling: (1) `UploadValidatable` concern validates file size and content-type before processing on all upload endpoints (STIGs, SRGs, backups, components). (2) ActiveRecord validations enforce length limits on all user-editable text fields. (3) Strong parameters restrict mass assignment. (4) rack-attack rate limiting prevents abuse of login and upload endpoints. 21 dedicated security tests cover these protections.||02/20/2026|V-222609| |SI-11 a|The application must generate error messages that provide information necessary for corrective actions without revealing information that could be exploited by adversaries.|Configure the server to not send error messages containing system information or sensitive data to users. Use generic error messages.|Vulcan returns generic error messages, such as 401, to users without revealing sensitive information.||10/11/2024|V-222610| |SI-11 b|The application must reveal error messages only to the ISSO, ISSM, or SA.|Configure the server to only send error messages containing system information or sensitive data to privileged users. Use generic error messages for non-privileged users.|Vulcan returns generic error messages, such as 401, to users without revealing sensitive information.||10/11/2024|V-222611| |SI-16|The application must not be vulnerable to overflow attacks.|Design the application to use a language or compiler that performs automatic bounds checking. Use an abstraction library to abstract away risky APIs. Use compiler-based canary mechanisms such as StackGuard, ProPolice, and the Microsoft Visual Studio/GS flag. Use OS-level preventative functionality and control user input validation. Patch applications when overflows are identified in vendor products.|The Vulcan Server GitHub repository undergoes continuous automated dependency, static, and secrets scanning, and each release is secured/patched accordingly. Unit tests are performed via GitHub Actions at every commit, including to validate security functionality.||10/11/2024|V-222612| \ No newline at end of file From aa8a5028cd8d53c3e28608ac7b53ec9e446ed522 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 21 Feb 2026 23:34:27 -0500 Subject: [PATCH 304/428] =?UTF-8?q?fix:=20avoid=20cleartext=20password=20s?= =?UTF-8?q?torage=20during=20bcrypt=E2=86=92PBKDF2=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use encryptor_class.digest() and update_columns directly instead of self.password= which holds cleartext in @password instance variable. Resolves CodeQL alert rb/clear-text-storage-sensitive-data (#41). Authored by: Aaron Lippold --- app/models/user.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index ea3b36898..d051d113a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -47,9 +47,11 @@ def valid_password?(password) require 'bcrypt' result = BCrypt::Password.new(encrypted_password) == password if result - # Re-hash with PBKDF2-SHA512 - self.password = password - save(validate: false) + # Re-hash with PBKDF2-SHA512 directly via encryptor to avoid + # holding cleartext in @password (CodeQL rb/clear-text-storage-sensitive-data) + new_salt = self.class.password_salt + new_hash = encryptor_class.digest(password, self.class.stretches, new_salt, self.class.pepper) + update_columns(encrypted_password: new_hash, password_salt: new_salt) # rubocop:disable Rails/SkipsModelValidations -- intentional: avoid callbacks during migration end result else From d62639cfc4a0f7d6668967304220a21cdb3a62ce Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 21 Feb 2026 23:39:55 -0500 Subject: [PATCH 305/428] fix: resolve SonarCloud security hotspots (ReDoS, PRNG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useFormValidation: replace backtracking-vulnerable email regex with bounded character classes to prevent ReDoS (S5852) - RuleFormGroup: replace Math.random() with incrementing counter for unique IDs — crypto PRNG not needed for DOM IDs (S2245) - Marked 16 test-file hotspots as SAFE in SonarCloud (fixture data) Authored by: Aaron Lippold --- app/javascript/components/shared/RuleFormGroup.vue | 4 +++- app/javascript/composables/useFormValidation.js | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/javascript/components/shared/RuleFormGroup.vue b/app/javascript/components/shared/RuleFormGroup.vue index 6ee02fb64..957a0258e 100644 --- a/app/javascript/components/shared/RuleFormGroup.vue +++ b/app/javascript/components/shared/RuleFormGroup.vue @@ -45,6 +45,8 @@ + + diff --git a/app/javascript/components/shared/ControlsCommandBar.vue b/app/javascript/components/shared/ControlsCommandBar.vue index 9c813604a..1fbfb8695 100644 --- a/app/javascript/components/shared/ControlsCommandBar.vue +++ b/app/javascript/components/shared/ControlsCommandBar.vue @@ -34,6 +34,13 @@ Release + + + @@ -117,11 +124,12 @@ import RoleComparisonMixin from "../../mixins/RoleComparisonMixin.vue"; import DateFormatMixinVue from "../../mixins/DateFormatMixin.vue"; import BaseCommandBar from "./BaseCommandBar.vue"; +import UpdateFromSpreadsheetModal from "../components/UpdateFromSpreadsheetModal.vue"; import { PANEL_LABELS } from "../../constants/terminology"; export default { name: "ControlsCommandBar", - components: { BaseCommandBar }, + components: { BaseCommandBar, UpdateFromSpreadsheetModal }, mixins: [RoleComparisonMixin, DateFormatMixinVue], props: { component: { @@ -204,6 +212,9 @@ export default { onTogglePanel(panel) { this.$emit("toggle-panel", panel); }, + onSpreadsheetUpdated() { + this.$emit("spreadsheet-updated"); + }, }, }; diff --git a/spec/javascript/components/components/UpdateFromSpreadsheetModal.spec.js b/spec/javascript/components/components/UpdateFromSpreadsheetModal.spec.js new file mode 100644 index 000000000..c527e9443 --- /dev/null +++ b/spec/javascript/components/components/UpdateFromSpreadsheetModal.spec.js @@ -0,0 +1,379 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import { localVue } from "@test/testHelper"; +import UpdateFromSpreadsheetModal from "@/components/components/UpdateFromSpreadsheetModal.vue"; + +/** + * UpdateFromSpreadsheetModal Contract Tests + * + * REQUIREMENTS: + * 1. Shows file input on open (Step 1) + * 2. Preview button disabled until file selected + * 3. Advances to preview after successful preview API call (Step 2) + * 4. Tracks locked rules in preview data + * 5. Shows error on API failure + * 6. Progress spinner state during update (Step 4) + * 7. Success result on completion (Step 5) + * 8. Emits spreadsheet-updated on success close + * + * NOTE: b-modal renders body lazily in jsdom. Tests that verify modal body + * DOM use attachTo: document.body. Tests for computed/state use vm directly. + */ + +vi.mock("axios", () => ({ + default: { + post: vi.fn(() => Promise.resolve({ data: {} })), + patch: vi.fn(() => Promise.resolve({ data: {} })), + defaults: { headers: { common: {} } }, + }, +})); + +import axios from "axios"; + +describe("UpdateFromSpreadsheetModal", () => { + let wrapper; + + const defaultProps = { + component: { id: 42, name: "Test Component", prefix: "TEST-01" }, + }; + + const mockPreviewResponse = { + data: { + updated: [ + { + rule_id: "001", + srg_id: "SRG-OS-000001", + changes: { title: { from: "Old Title", to: "New Title" } }, + }, + { + rule_id: "002", + srg_id: "SRG-OS-000002", + changes: { fixtext: { from: "Old Fix", to: "New Fix" } }, + }, + ], + unchanged: [{ rule_id: "003", srg_id: "SRG-OS-000003" }], + skipped_locked: [{ rule_id: "004", srg_id: "SRG-OS-000004" }], + warnings: ["Column 'extra_col' was ignored"], + }, + }; + + const createWrapper = (props = {}) => { + const div = document.createElement("div"); + document.body.appendChild(div); + return mount(UpdateFromSpreadsheetModal, { + localVue, + attachTo: div, + propsData: { ...defaultProps, ...props }, + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe("Step 1: File Select", () => { + it("renders the opener button", () => { + wrapper = createWrapper(); + expect(wrapper.find('[data-testid="update-from-spreadsheet-btn"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="update-from-spreadsheet-btn"]').text()).toContain( + "Update from Spreadsheet", + ); + }); + + it("has Preview button disabled when no file selected (computed)", () => { + wrapper = createWrapper(); + expect(wrapper.vm.modalOkDisabled).toBe(true); + }); + + it("enables Preview button when file is selected (computed)", async () => { + wrapper = createWrapper(); + wrapper.vm.selectedFile = new File(["content"], "test.csv", { type: "text/csv" }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.modalOkDisabled).toBe(false); + }); + }); + + describe("Step 2: Preview (state verification)", () => { + it("advances to step 2 after successful preview API call", async () => { + axios.post.mockResolvedValueOnce(mockPreviewResponse); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + await wrapper.vm.fetchPreview(); + + expect(wrapper.vm.step).toBe(2); + expect(wrapper.vm.previewData.updated).toHaveLength(2); + expect(wrapper.vm.previewData.unchanged).toHaveLength(1); + expect(wrapper.vm.previewData.skipped_locked).toHaveLength(1); + expect(wrapper.vm.previewData.warnings).toHaveLength(1); + }); + + it("sets correct modal title for preview step", async () => { + axios.post.mockResolvedValueOnce(mockPreviewResponse); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + await wrapper.vm.fetchPreview(); + + expect(wrapper.vm.modalTitle).toContain("2 rules to update"); + }); + + it("sets modal size to xl for preview step", async () => { + axios.post.mockResolvedValueOnce(mockPreviewResponse); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + await wrapper.vm.fetchPreview(); + + expect(wrapper.vm.modalSize).toBe("xl"); + }); + + it("builds table items from preview data", async () => { + axios.post.mockResolvedValueOnce(mockPreviewResponse); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + await wrapper.vm.fetchPreview(); + + expect(wrapper.vm.updatedTableItems).toHaveLength(2); + expect(wrapper.vm.updatedTableItems[0].rule_id).toBe("001"); + expect(wrapper.vm.updatedTableItems[0].changes).toHaveProperty("title"); + }); + }); + + describe("Error handling", () => { + it("stays on step 1 and sets fileError on preview API failure", async () => { + axios.post.mockRejectedValueOnce({ + response: { data: { error: "Missing required header: SRGID" } }, + }); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + await wrapper.vm.fetchPreview(); + + expect(wrapper.vm.step).toBe(1); + expect(wrapper.vm.fileError).toBe("Missing required header: SRGID"); + }); + + it("sets generic error when API response has no error field", async () => { + axios.post.mockRejectedValueOnce({ response: { data: {} } }); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + await wrapper.vm.fetchPreview(); + + expect(wrapper.vm.fileError).toBe("Failed to preview spreadsheet"); + }); + + it("sets error result on apply failure", async () => { + axios.patch.mockRejectedValueOnce({ + response: { data: { error: "Could not save rules" } }, + }); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + wrapper.vm.previewData = mockPreviewResponse.data; + + await wrapper.vm.applyChanges(); + + expect(wrapper.vm.step).toBe(5); + expect(wrapper.vm.updateResult.success).toBe(false); + expect(wrapper.vm.updateResult.message).toBe("Could not save rules"); + }); + }); + + describe("Step 4: Progress", () => { + it("sets step to 4 during update", async () => { + let resolveApply; + axios.patch.mockReturnValueOnce( + new Promise((resolve) => { + resolveApply = resolve; + }), + ); + + wrapper = createWrapper(); + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + wrapper.vm.previewData = mockPreviewResponse.data; + + const applyPromise = wrapper.vm.applyChanges(); + + // Step should be 4 immediately (synchronous assignment before async) + expect(wrapper.vm.step).toBe(4); + expect(wrapper.vm.modalTitle).toBe("Updating rules..."); + expect(wrapper.vm.modalOkDisabled).toBe(true); + + resolveApply({ data: { toast: "Done" } }); + await applyPromise; + }); + }); + + describe("Step 5: Results", () => { + it("sets success result on completion", async () => { + axios.patch.mockResolvedValueOnce({ + data: { toast: "Successfully updated 2 rules from spreadsheet." }, + }); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + wrapper.vm.previewData = mockPreviewResponse.data; + + await wrapper.vm.applyChanges(); + + expect(wrapper.vm.step).toBe(5); + expect(wrapper.vm.updateResult.success).toBe(true); + expect(wrapper.vm.updateResult.message).toBe( + "Successfully updated 2 rules from spreadsheet.", + ); + expect(wrapper.vm.modalTitle).toBe("Update Complete"); + }); + + it("emits spreadsheet-updated on success close", () => { + wrapper = createWrapper(); + wrapper.vm.updateResult = { success: true, message: "Done" }; + wrapper.vm.step = 5; + + wrapper.vm.onResultsClose(); + + expect(wrapper.emitted("spreadsheet-updated")).toBeTruthy(); + }); + + it("does not emit spreadsheet-updated on error close", () => { + wrapper = createWrapper(); + wrapper.vm.updateResult = { success: false, message: "Failed" }; + wrapper.vm.step = 5; + + wrapper.vm.onResultsClose(); + + expect(wrapper.emitted("spreadsheet-updated")).toBeFalsy(); + }); + }); + + describe("Modal navigation", () => { + it("resets state on modal hidden", () => { + wrapper = createWrapper(); + wrapper.vm.step = 3; + wrapper.vm.fileError = "some error"; + wrapper.vm.selectedFile = new File(["x"], "x.csv"); + + wrapper.vm.onHidden(); + + expect(wrapper.vm.step).toBe(1); + expect(wrapper.vm.fileError).toBeNull(); + expect(wrapper.vm.selectedFile).toBeNull(); + expect(wrapper.vm.previewData.updated).toHaveLength(0); + }); + + it("does not fetch preview without a file", async () => { + wrapper = createWrapper(); + wrapper.vm.selectedFile = null; + + wrapper.vm.fetchPreview(); + + expect(wrapper.vm.fileError).toBe("Please select a file"); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it("calls correct API endpoint for preview", async () => { + axios.post.mockResolvedValueOnce(mockPreviewResponse); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + await wrapper.vm.fetchPreview(); + + expect(axios.post).toHaveBeenCalledWith( + "/components/42/preview_spreadsheet_update", + expect.any(FormData), + expect.objectContaining({ + headers: { "Content-Type": "multipart/form-data" }, + }), + ); + }); + + it("calls correct API endpoint for apply", async () => { + axios.patch.mockResolvedValueOnce({ data: { toast: "Done" } }); + wrapper = createWrapper(); + + wrapper.vm.selectedFile = new File(["content"], "test.csv"); + wrapper.vm.previewData = mockPreviewResponse.data; + + await wrapper.vm.applyChanges(); + + expect(axios.patch).toHaveBeenCalledWith( + "/components/42/apply_spreadsheet_update", + expect.any(FormData), + expect.objectContaining({ + headers: { "Content-Type": "multipart/form-data" }, + }), + ); + }); + }); + + describe("Computed properties", () => { + it("returns correct ok title per step", () => { + wrapper = createWrapper(); + + wrapper.vm.step = 1; + expect(wrapper.vm.modalOkTitle).toBe("Preview"); + + // Step 2 with updates shows "Apply Changes" + wrapper.vm.previewData = { + updated: [{ rule_id: "R1", changes: {} }], + unchanged: [], + skipped_locked: [], + warnings: [], + }; + wrapper.vm.step = 2; + expect(wrapper.vm.modalOkTitle).toBe("Apply Changes"); + + wrapper.vm.step = 3; + expect(wrapper.vm.modalOkTitle).toBe("Yes, Update"); + + wrapper.vm.step = 5; + expect(wrapper.vm.modalOkTitle).toBe("Close"); + }); + + it("returns Done ok title at step 2 when no updates", () => { + wrapper = createWrapper(); + wrapper.vm.step = 2; + expect(wrapper.vm.modalOkTitle).toBe("Done"); + }); + + it("returns correct cancel title per step", () => { + wrapper = createWrapper(); + + wrapper.vm.step = 1; + expect(wrapper.vm.modalCancelTitle).toBe("Cancel"); + + wrapper.vm.step = 2; + expect(wrapper.vm.modalCancelTitle).toBe("Back"); + + wrapper.vm.step = 3; + expect(wrapper.vm.modalCancelTitle).toBe("Back"); + }); + + it("shows Loading... ok title when updateInProgress", () => { + wrapper = createWrapper(); + wrapper.vm.step = 1; + wrapper.vm.updateInProgress = true; + expect(wrapper.vm.modalOkTitle).toBe("Loading..."); + }); + + it("truncates long strings", () => { + wrapper = createWrapper(); + const short = "hello"; + const long = "a".repeat(100); + + expect(wrapper.vm.truncate(short)).toBe("hello"); + expect(wrapper.vm.truncate(long).length).toBe(80); + expect(wrapper.vm.truncate(long)).toContain("..."); + expect(wrapper.vm.truncate(null)).toBe(""); + }); + }); +}); From 82c8c0ef41590889a83141b314a542163a7dd522 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 12:21:44 -0500 Subject: [PATCH 311/428] feat: input length limits, CSP headers, and detailed import errors - Add length validations to BaseRule (255/1000/2048/10000/50000), DisaRuleDescription (10000), and Check (255/10000) fields - Limits based on analysis of real DISA STIG content (RHEL 9 V2R7) - ident field gets 2048 (comma-joined CCI list, real data hits 310+) - Enable Content-Security-Policy header: default-src self, style-src unsafe-inline (required by Vue/BootstrapVue inline styles) - Replace generic "Some rules failed to import" with detailed errors including rule ID, field name, and specific validation message - Add docs/development/input-length-limits.md reference 46 new tests: 17 BaseRule, 11 DisaRuleDescription, 4 Check length validations + 11 error message regression + 3 CSP header tests Authored by: Aaron Lippold --- app/models/base_rule.rb | 12 +++ app/models/check.rb | 4 + app/models/disa_rule_description.rb | 5 + app/models/security_requirements_guide.rb | 4 +- app/models/stig.rb | 4 +- .../initializers/content_security_policy.rb | 43 ++++----- docs/development/input-length-limits.md | 94 +++++++++++++++++++ .../base_rule_length_validations_spec.rb | 41 ++++++++ spec/models/check_length_validations_spec.rb | 15 +++ ...ule_description_length_validations_spec.rb | 16 ++++ spec/models/import_error_detail_spec.rb | 88 +++++++++++++++++ spec/requests/content_security_policy_spec.rb | 29 ++++++ 12 files changed, 329 insertions(+), 26 deletions(-) create mode 100644 docs/development/input-length-limits.md create mode 100644 spec/models/base_rule_length_validations_spec.rb create mode 100644 spec/models/check_length_validations_spec.rb create mode 100644 spec/models/disa_rule_description_length_validations_spec.rb create mode 100644 spec/models/import_error_detail_spec.rb create mode 100644 spec/requests/content_security_policy_spec.rb diff --git a/app/models/base_rule.rb b/app/models/base_rule.rb index c82c68313..0556a13d9 100644 --- a/app/models/base_rule.rb +++ b/app/models/base_rule.rb @@ -34,6 +34,18 @@ class BaseRule < ApplicationRecord message: "is not an acceptable value, acceptable values are: '#{SEVERITIES.compact_blank.join("', '")}'" } + # Length limits to prevent abuse via oversized input + validates :rule_id, :rule_weight, :version, :ident_system, + :fixtext_fixref, :fix_id, :srg_id, :vuln_id, :legacy_ids, + length: { maximum: 255 }, allow_nil: true + validates :ident, length: { maximum: 2_048 }, allow_nil: true # comma-joined CCI list + validates :title, :status_justification, + length: { maximum: 1_000 }, allow_nil: true + validates :fixtext, :artifact_description, :vendor_comments, + length: { maximum: 10_000 }, allow_nil: true + validates :inspec_control_body, :inspec_control_file, + length: { maximum: 50_000 }, allow_nil: true + # In all cases of has_many, it is very unlikely (based on past releases of SRGs # that there will be multiple of these fields. Just take the first one. # Extend the model if required diff --git a/app/models/check.rb b/app/models/check.rb index da3bbfe29..5c1680090 100644 --- a/app/models/check.rb +++ b/app/models/check.rb @@ -5,6 +5,10 @@ class Check < ApplicationRecord audited associated_with: :base_rule, on: %i[update], except: %i[base_rule_id], max_audits: 1000 belongs_to :base_rule + validates :system, :content_ref_name, :content_ref_href, + length: { maximum: 255 }, allow_nil: true + validates :content, length: { maximum: 10_000 }, allow_nil: true + # Because from_mappings take advantage of accepts_nested_attributes, these methods # must return Hashes instead of an actual object to be properly created and associated # with the rule. diff --git a/app/models/disa_rule_description.rb b/app/models/disa_rule_description.rb index 1939ca0ce..fd24d7037 100644 --- a/app/models/disa_rule_description.rb +++ b/app/models/disa_rule_description.rb @@ -7,6 +7,11 @@ class DisaRuleDescription < ApplicationRecord audited associated_with: :base_rule, on: %i[update], except: %i[base_rule_id], max_audits: 1000 belongs_to :base_rule + validates :vuln_discussion, :false_positives, :false_negatives, :mitigations, + :severity_override_guidance, :potential_impacts, :third_party_tools, + :mitigation_control, :responsibility, :ia_controls, :poam, + length: { maximum: 10_000 }, allow_nil: true + # Because from_mappings take advantage of accepts_nested_attributes, these methods # must return Hashes instead of an actual object to be properly created and associated # with the rule. diff --git a/app/models/security_requirements_guide.rb b/app/models/security_requirements_guide.rb index de3c12159..f3e7b8e23 100644 --- a/app/models/security_requirements_guide.rb +++ b/app/models/security_requirements_guide.rb @@ -100,7 +100,9 @@ def import_srg_rules if failures.empty? reload else - errors.add(:base, 'Some rules failed to import successfully for the SRG.') + detail = failures.first(3).map { |r| "#{r.rule_id}: #{r.errors.full_messages.join(', ')}" }.join('; ') + detail += " (and #{failures.size - 3} more)" if failures.size > 3 + errors.add(:base, "#{failures.size} rules failed to import: #{detail}") raise ActiveRecord::Rollback end end diff --git a/app/models/stig.rb b/app/models/stig.rb index f7a1e31aa..804cb5ade 100644 --- a/app/models/stig.rb +++ b/app/models/stig.rb @@ -46,7 +46,9 @@ def import_stig_rules if failures.empty? reload else - errors.add(:base, 'Some rules failed to import successfully for the SRG.') + detail = failures.first(3).map { |r| "#{r.rule_id}: #{r.errors.full_messages.join(', ')}" }.join('; ') + detail += " (and #{failures.size - 3} more)" if failures.size > 3 + errors.add(:base, "#{failures.size} rules failed to import: #{detail}") raise ActiveRecord::Rollback end end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 35ab3fd6a..ff5696682 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,27 +1,22 @@ # 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 - -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end +# Content Security Policy — mitigates XSS by restricting allowed sources. +# See: https://guides.rubyonrails.org/security.html#content-security-policy-header # -# # Generate session nonces for permitted importmap, inline scripts, and inline styles. -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src style-src) -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +# Vulcan bundles all JS/CSS locally via esbuild (no CDN dependencies). +# Vue 2 + BootstrapVue use inline styles, so style-src requires 'unsafe-inline'. + +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :self + policy.font_src :self, :data + policy.img_src :self, :data + policy.object_src :none + policy.script_src :self + policy.style_src :self, :unsafe_inline + policy.connect_src :self + policy.frame_src :none + policy.base_uri :self + policy.form_action :self + end +end diff --git a/docs/development/input-length-limits.md b/docs/development/input-length-limits.md new file mode 100644 index 000000000..54eac435b --- /dev/null +++ b/docs/development/input-length-limits.md @@ -0,0 +1,94 @@ +# Input Length Limits + +All text fields in Vulcan enforce maximum lengths to prevent abuse and protect downstream consumers (Excel export, API responses, PDF generation). + +## BaseRule Fields (base_rules table) + +| Field | Type | Max Length | Rationale | +|---|---|---|---| +| `rule_id` | string | 255 | Identifier | +| `rule_weight` | string | 255 | Numeric string | +| `version` | string | 255 | Version identifier | +| `ident_system` | string | 255 | URI | +| `fixtext_fixref` | string | 255 | Reference ID | +| `fix_id` | string | 255 | Reference ID | +| `srg_id` | string | 255 | Identifier | +| `vuln_id` | string | 255 | Identifier | +| `legacy_ids` | string | 255 | Comma-separated IDs | +| `ident` | string | 2,048 | Comma-joined CCI list (real STIGs have 310+ chars) | +| `title` | text | 1,000 | Rule title | +| `status_justification` | text | 1,000 | Brief justification | +| `fixtext` | text | 10,000 | Fix instructions | +| `artifact_description` | text | 10,000 | Artifact details | +| `vendor_comments` | text | 10,000 | Vendor notes | +| `inspec_control_body` | text | 50,000 | InSpec Ruby code | +| `inspec_control_file` | text | 50,000 | InSpec file content | + +**Not length-validated** (constrained by inclusion validation): +- `rule_severity` — only `low`, `medium`, `high`, or empty string +- `status` — only values in `RuleConstants::STATUSES` + +## DisaRuleDescription Fields (disa_rule_descriptions table) + +| Field | Max Length | Rationale | +|---|---|---| +| `vuln_discussion` | 10,000 | DISA vulnerability discussion | +| `false_positives` | 10,000 | False positive notes | +| `false_negatives` | 10,000 | False negative notes | +| `mitigations` | 10,000 | Mitigation description | +| `severity_override_guidance` | 10,000 | Override guidance | +| `potential_impacts` | 10,000 | Impact description | +| `third_party_tools` | 10,000 | Tool references | +| `mitigation_control` | 10,000 | Control description | +| `responsibility` | 10,000 | Responsibility assignment | +| `ia_controls` | 10,000 | IA control references | +| `poam` | 10,000 | Plan of Action & Milestones | + +## Check Fields (checks table) + +| Field | Max Length | Rationale | +|---|---|---| +| `system` | 255 | Check system identifier | +| `content_ref_name` | 255 | Reference name | +| `content_ref_href` | 255 | Reference URL | +| `content` | 10,000 | Check content (verification steps) | + +## Component Fields (components table) + +| Field | Max Length | Rationale | +|---|---|---| +| `name` | 255 | Component name | +| `prefix` | 10 | STIG ID prefix | +| `title` | 500 | Component title | +| `description` | 5,000 | Component description | + +## Project Fields (projects table) + +| Field | Max Length | Rationale | +|---|---|---| +| `name` | 255 | Project name | +| `description` | 5,000 | Project description | + +## Upload Limits + +| Endpoint | Max Size | Allowed Types | +|---|---|---| +| STIG upload (XML) | 50 MB | `.xml` | +| SRG upload (XML) | 50 MB | `.xml` | +| Spreadsheet import | 50 MB | `.xlsx`, `.csv` | +| JSON Archive import | 100 MB | `.zip` | + +## Error Behavior + +When a length validation fails: +- **Direct model save**: `ActiveRecord::RecordInvalid` with message like "Title is too long (maximum is 1000 characters)" +- **STIG/SRG XML import**: Error includes rule ID and specific field: "3 rules failed to import: SV-12345: Title is too long (maximum is 1000 characters)" +- **Spreadsheet import**: Validation errors surface per-rule in the preview modal +- **API responses**: 422 with `errors.full_messages` array + +## Design Decisions + +- Limits based on analysis of real DISA STIG/SRG content (RHEL 9 V2R7: max ident=310, vuln_discussion=2905, check_content=2367, fixtext=1756) +- `ident` gets 2,048 (not 255) because it's a comma-joined CCI list that grows with rule scope +- InSpec fields get 50,000 because generated control code can be substantial +- All limits use `allow_nil: true` to avoid breaking existing nil-valued records diff --git a/spec/models/base_rule_length_validations_spec.rb b/spec/models/base_rule_length_validations_spec.rb new file mode 100644 index 000000000..d164a3061 --- /dev/null +++ b/spec/models/base_rule_length_validations_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENT: Text fields on base_rules must enforce maximum lengths to prevent +# abuse via oversized input. These limits protect the database and downstream +# consumers (Excel export, PDF generation, API responses). +# +# Limits based on realistic STIG content maximums with headroom: +# Short strings (IDs, names): 255 chars +# Medium text (title): 1_000 chars +# Long text (descriptions): 10_000 chars +# Very long (InSpec bodies): 50_000 chars +RSpec.describe BaseRule do + describe 'text field length validations' do + # Short string fields (255) + # rule_severity excluded: constrained by inclusion validation (low/medium/high) + %i[rule_id rule_weight version ident_system + fixtext_fixref fix_id srg_id vuln_id legacy_ids].each do |field| + it { is_expected.to validate_length_of(field).is_at_most(255).allow_nil } + end + + # ident is a comma-joined CCI list — real STIG data can have 310+ chars + it { is_expected.to validate_length_of(:ident).is_at_most(2_048).allow_nil } + + # Medium text (1_000) + %i[title status_justification].each do |field| + it { is_expected.to validate_length_of(field).is_at_most(1_000).allow_nil } + end + + # Long text (10_000) + %i[fixtext artifact_description vendor_comments].each do |field| + it { is_expected.to validate_length_of(field).is_at_most(10_000).allow_nil } + end + + # Very long text (50_000) + %i[inspec_control_body inspec_control_file].each do |field| + it { is_expected.to validate_length_of(field).is_at_most(50_000).allow_nil } + end + end +end diff --git a/spec/models/check_length_validations_spec.rb b/spec/models/check_length_validations_spec.rb new file mode 100644 index 000000000..6a65adb5d --- /dev/null +++ b/spec/models/check_length_validations_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENT: Check content (check_content in exports) can be lengthy but must +# have an upper bound. +RSpec.describe Check do + describe 'text field length validations' do + %i[system content_ref_name content_ref_href].each do |field| + it { is_expected.to validate_length_of(field).is_at_most(255).allow_nil } + end + + it { is_expected.to validate_length_of(:content).is_at_most(10_000).allow_nil } + end +end diff --git a/spec/models/disa_rule_description_length_validations_spec.rb b/spec/models/disa_rule_description_length_validations_spec.rb new file mode 100644 index 000000000..5a2332a9a --- /dev/null +++ b/spec/models/disa_rule_description_length_validations_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENT: Text fields on disa_rule_descriptions must enforce maximum lengths. +# vuln_discussion can be lengthy (DISA content), others are shorter metadata. +RSpec.describe DisaRuleDescription do + describe 'text field length validations' do + # Long text (10_000) + %i[vuln_discussion false_positives false_negatives mitigations + severity_override_guidance potential_impacts third_party_tools + mitigation_control responsibility ia_controls poam].each do |field| + it { is_expected.to validate_length_of(field).is_at_most(10_000).allow_nil } + end + end +end diff --git a/spec/models/import_error_detail_spec.rb b/spec/models/import_error_detail_spec.rb new file mode 100644 index 000000000..ee051115d --- /dev/null +++ b/spec/models/import_error_detail_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENT: Validation error messages must include the specific field name +# and limit so users can fix their data. No generic "failed to import" messages. +RSpec.describe 'Input length validation error messages' do + describe 'BaseRule' do + # Use StigRule as concrete subclass (BaseRule is abstract via STI) + let(:rule) { StigRule.new(rule_id: 'TEST-001', status: 'Not Yet Determined', rule_severity: 'medium') } + + it 'reports field name and max for title' do + rule.title = 'x' * 1001 + rule.valid? + expect(rule.errors.full_messages).to include('Title is too long (maximum is 1000 characters)') + end + + it 'reports field name and max for fixtext' do + rule.fixtext = 'x' * 10_001 + rule.valid? + expect(rule.errors.full_messages).to include('Fixtext is too long (maximum is 10000 characters)') + end + + it 'reports field name and max for ident' do + rule.ident = 'C' * 2049 + rule.valid? + expect(rule.errors.full_messages).to include('Ident is too long (maximum is 2048 characters)') + end + + it 'reports field name and max for inspec_control_body' do + rule.inspec_control_body = 'x' * 50_001 + rule.valid? + expect(rule.errors.full_messages).to include('Inspec control body is too long (maximum is 50000 characters)') + end + + it 'allows values at exactly the limit' do + rule.title = 'x' * 1000 + rule.fixtext = 'x' * 10_000 + rule.ident = 'CCI-000001' + rule.valid? + expect(rule.errors[:title]).to be_empty + expect(rule.errors[:fixtext]).to be_empty + expect(rule.errors[:ident]).to be_empty + end + end + + describe 'DisaRuleDescription' do + it 'reports field name and max for vuln_discussion' do + desc = DisaRuleDescription.new(vuln_discussion: 'x' * 10_001) + desc.valid? + expect(desc.errors.full_messages).to include('Vuln discussion is too long (maximum is 10000 characters)') + end + + it 'allows values at exactly the limit' do + desc = DisaRuleDescription.new(vuln_discussion: 'x' * 10_000) + desc.valid? + expect(desc.errors[:vuln_discussion]).to be_empty + end + end + + describe 'Check' do + it 'reports field name and max for content' do + check = Check.new(content: 'x' * 10_001) + check.valid? + expect(check.errors.full_messages).to include('Content is too long (maximum is 10000 characters)') + end + + it 'reports field name and max for system' do + check = Check.new(system: 'x' * 256) + check.valid? + expect(check.errors.full_messages).to include('System is too long (maximum is 255 characters)') + end + end + + describe 'Import error message format' do + it 'Stig model includes rule detail in error messages' do + source = Rails.root.join('app/models/stig.rb').read + expect(source).to include('rules failed to import:') + expect(source).not_to include('Some rules failed to import successfully') + end + + it 'SRG model includes rule detail in error messages' do + source = Rails.root.join('app/models/security_requirements_guide.rb').read + expect(source).to include('rules failed to import:') + expect(source).not_to include('Some rules failed to import successfully') + end + end +end diff --git a/spec/requests/content_security_policy_spec.rb b/spec/requests/content_security_policy_spec.rb new file mode 100644 index 000000000..a2021a652 --- /dev/null +++ b/spec/requests/content_security_policy_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENT: All HTML responses must include a Content-Security-Policy header +# to mitigate XSS attacks. The policy should restrict script and style sources +# to self and configured CDN origins. +RSpec.describe 'Content-Security-Policy header' do + before do + Rails.application.reload_routes! + end + + it 'is present on HTML responses' do + get root_path + expect(response.headers['Content-Security-Policy']).to be_present + end + + it 'restricts default-src to self' do + get root_path + csp = response.headers['Content-Security-Policy'] + expect(csp).to match(/default-src\s+'self'/) + end + + it 'restricts script-src' do + get root_path + csp = response.headers['Content-Security-Policy'] + expect(csp).to match(/script-src\s/) + end +end From a2f8d75513ac8611ec89ef61857f88a5af22ebcf Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 13:47:11 -0500 Subject: [PATCH 312/428] feat: make input length limits configurable via Settings All text field length validations now read from Settings.input_limits with VULCAN_LIMIT_* env var overrides. Admins can tune per deployment. 18 configurable knobs grouped by category (short_string, long_text, etc.). 100% field coverage across 9 models based on real DISA data analysis. Authored by: Aaron Lippold --- ENVIRONMENT_VARIABLES.md | 30 ++++ app/models/base_rule.rb | 19 ++- app/models/check.rb | 6 +- app/models/component.rb | 11 +- app/models/disa_rule_description.rb | 3 +- app/models/project.rb | 7 +- app/models/review.rb | 2 + app/models/security_requirements_guide.rb | 5 + app/models/stig.rb | 6 + app/models/user.rb | 4 +- config/vulcan.default.yml | 22 +++ docs/development/input-length-limits.md | 185 ++++++++++++++-------- 12 files changed, 213 insertions(+), 87 deletions(-) diff --git a/ENVIRONMENT_VARIABLES.md b/ENVIRONMENT_VARIABLES.md index 067109cef..52ced7ba6 100644 --- a/ENVIRONMENT_VARIABLES.md +++ b/ENVIRONMENT_VARIABLES.md @@ -257,6 +257,36 @@ DoD-aligned defaults ("2222" policy). Set any count to `0` to disable that requi | `VULCAN_PASSWORD_MIN_NUMBER` | Minimum digits | `2` | `0` | | `VULCAN_PASSWORD_MIN_SPECIAL` | Minimum special characters | `2` | `0` | +## Input Length Limits + +Configurable maximum lengths for text fields. Defaults are based on analysis of real DISA STIG/SRG +data across 1,785 rules. Group limits by category rather than individual fields — each env var +controls a category of related fields. + +See [docs/development/input-length-limits.md](docs/development/input-length-limits.md) for the +complete field-to-setting mapping. + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VULCAN_LIMIT_SHORT_STRING` | IDs, version strings, reference fields | `255` | `512` | +| `VULCAN_LIMIT_IDENT` | Comma-joined CCI list (real max: 310) | `2048` | `4096` | +| `VULCAN_LIMIT_TITLE` | Rule titles (real max: 436) | `500` | `1000` | +| `VULCAN_LIMIT_MEDIUM_TEXT` | Status justification, brief text | `1000` | `2000` | +| `VULCAN_LIMIT_LONG_TEXT` | Descriptions, check content, fixtext (real max: 6,330) | `10000` | `20000` | +| `VULCAN_LIMIT_INSPEC_CODE` | InSpec control bodies (user-authored) | `50000` | `100000` | +| `VULCAN_LIMIT_COMPONENT_NAME` | Component name | `255` | `500` | +| `VULCAN_LIMIT_COMPONENT_PREFIX` | STIG ID prefix | `10` | `15` | +| `VULCAN_LIMIT_COMPONENT_TITLE` | Component title | `500` | `1000` | +| `VULCAN_LIMIT_COMPONENT_DESCRIPTION` | Component description | `5000` | `10000` | +| `VULCAN_LIMIT_PROJECT_NAME` | Project name | `255` | `500` | +| `VULCAN_LIMIT_PROJECT_DESCRIPTION` | Project description | `5000` | `10000` | +| `VULCAN_LIMIT_USER_NAME` | User display name | `255` | `500` | +| `VULCAN_LIMIT_USER_EMAIL` | User email address | `255` | `500` | +| `VULCAN_LIMIT_REVIEW_COMMENT` | Review comments | `10000` | `20000` | +| `VULCAN_LIMIT_BENCHMARK_NAME` | SRG/STIG display name | `500` | `1000` | +| `VULCAN_LIMIT_BENCHMARK_TITLE` | SRG/STIG title | `500` | `1000` | +| `VULCAN_LIMIT_BENCHMARK_DESCRIPTION` | STIG description | `10000` | `20000` | + ## Project Settings | Variable | Description | Default | Example | diff --git a/app/models/base_rule.rb b/app/models/base_rule.rb index 0556a13d9..b89033f7b 100644 --- a/app/models/base_rule.rb +++ b/app/models/base_rule.rb @@ -34,17 +34,22 @@ class BaseRule < ApplicationRecord message: "is not an acceptable value, acceptable values are: '#{SEVERITIES.compact_blank.join("', '")}'" } - # Length limits to prevent abuse via oversized input + # Length limits — configurable via Settings.input_limits (env vars: VULCAN_LIMIT_*) validates :rule_id, :rule_weight, :version, :ident_system, :fixtext_fixref, :fix_id, :srg_id, :vuln_id, :legacy_ids, - length: { maximum: 255 }, allow_nil: true - validates :ident, length: { maximum: 2_048 }, allow_nil: true # comma-joined CCI list - validates :title, :status_justification, - length: { maximum: 1_000 }, allow_nil: true + length: { maximum: ->(_r) { Settings.input_limits.short_string } }, allow_nil: true + validates :ident, + length: { maximum: ->(_r) { Settings.input_limits.ident } }, allow_nil: true + validates :title, + length: { maximum: ->(_r) { Settings.input_limits.title } }, allow_nil: true + validates :status_justification, + length: { maximum: ->(_r) { Settings.input_limits.medium_text } }, allow_nil: true validates :fixtext, :artifact_description, :vendor_comments, - length: { maximum: 10_000 }, allow_nil: true + length: { maximum: ->(_r) { Settings.input_limits.long_text } }, allow_nil: true validates :inspec_control_body, :inspec_control_file, - length: { maximum: 50_000 }, allow_nil: true + length: { maximum: ->(_r) { Settings.input_limits.inspec_code } }, allow_nil: true + validates :inspec_control_body_lang, :inspec_control_file_lang, + length: { maximum: ->(_r) { Settings.input_limits.short_string } }, allow_nil: true # In all cases of has_many, it is very unlikely (based on past releases of SRGs # that there will be multiple of these fields. Just take the first one. diff --git a/app/models/check.rb b/app/models/check.rb index 5c1680090..6b01c5318 100644 --- a/app/models/check.rb +++ b/app/models/check.rb @@ -5,9 +5,11 @@ class Check < ApplicationRecord audited associated_with: :base_rule, on: %i[update], except: %i[base_rule_id], max_audits: 1000 belongs_to :base_rule + # Length limits — configurable via Settings.input_limits (env vars: VULCAN_LIMIT_*) validates :system, :content_ref_name, :content_ref_href, - length: { maximum: 255 }, allow_nil: true - validates :content, length: { maximum: 10_000 }, allow_nil: true + length: { maximum: ->(_r) { Settings.input_limits.short_string } }, allow_nil: true + validates :content, + length: { maximum: ->(_r) { Settings.input_limits.long_text } }, allow_nil: true # Because from_mappings take advantage of accepts_nested_attributes, these methods # must return Hashes instead of an actual object to be properly created and associated diff --git a/app/models/component.rb b/app/models/component.rb index 5e64b3df8..a540dbcd7 100644 --- a/app/models/component.rb +++ b/app/models/component.rb @@ -68,10 +68,13 @@ class Component < ApplicationRecord validates_with PrefixValidator validates :name, :prefix, :title, presence: true - validates :name, length: { maximum: 255 } - validates :prefix, length: { maximum: 10 } - validates :title, length: { maximum: 500 } - validates :description, length: { maximum: 5000 } + # Length limits — configurable via Settings.input_limits (env vars: VULCAN_LIMIT_COMPONENT_*) + validates :name, length: { maximum: ->(_r) { Settings.input_limits.component_name } } + validates :prefix, length: { maximum: ->(_r) { Settings.input_limits.component_prefix } } + validates :title, length: { maximum: ->(_r) { Settings.input_limits.component_title } } + validates :description, length: { maximum: ->(_r) { Settings.input_limits.component_description } } + validates :admin_name, :admin_email, + length: { maximum: ->(_r) { Settings.input_limits.short_string } }, allow_nil: true validate :associated_component_must_be_released, :rules_must_be_locked_to_release_component, :cannot_unrelease_component, diff --git a/app/models/disa_rule_description.rb b/app/models/disa_rule_description.rb index fd24d7037..416750cde 100644 --- a/app/models/disa_rule_description.rb +++ b/app/models/disa_rule_description.rb @@ -7,10 +7,11 @@ class DisaRuleDescription < ApplicationRecord audited associated_with: :base_rule, on: %i[update], except: %i[base_rule_id], max_audits: 1000 belongs_to :base_rule + # Length limits — configurable via Settings.input_limits (env var: VULCAN_LIMIT_LONG_TEXT) validates :vuln_discussion, :false_positives, :false_negatives, :mitigations, :severity_override_guidance, :potential_impacts, :third_party_tools, :mitigation_control, :responsibility, :ia_controls, :poam, - length: { maximum: 10_000 }, allow_nil: true + length: { maximum: ->(_r) { Settings.input_limits.long_text } }, allow_nil: true # Because from_mappings take advantage of accepts_nested_attributes, these methods # must return Hashes instead of an actual object to be properly created and associated diff --git a/app/models/project.rb b/app/models/project.rb index 7c0cbe490..db6069e63 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -16,8 +16,11 @@ class Project < ApplicationRecord has_one :project_metadata, dependent: :destroy accepts_nested_attributes_for :project_metadata, :memberships - validates :name, presence: true, length: { maximum: 255 } - validates :description, length: { maximum: 5000 } + # Length limits — configurable via Settings.input_limits (env vars: VULCAN_LIMIT_PROJECT_*) + validates :name, presence: true, length: { maximum: ->(_r) { Settings.input_limits.project_name } } + validates :description, length: { maximum: ->(_r) { Settings.input_limits.project_description } } + validates :admin_name, :admin_email, + length: { maximum: ->(_r) { Settings.input_limits.short_string } }, allow_nil: true scope :alphabetical, -> { order(:name) } diff --git a/app/models/review.rb b/app/models/review.rb index f70013c75..44e2b4311 100644 --- a/app/models/review.rb +++ b/app/models/review.rb @@ -7,6 +7,8 @@ class Review < ApplicationRecord has_one :component, through: :rule validates :comment, :action, presence: true + validates :action, length: { maximum: ->(_r) { Settings.input_limits.short_string } } + validates :comment, length: { maximum: ->(_r) { Settings.input_limits.review_comment } } before_create :take_review_action validate :validate_project_permissions diff --git a/app/models/security_requirements_guide.rb b/app/models/security_requirements_guide.rb index f3e7b8e23..95e2aa491 100644 --- a/app/models/security_requirements_guide.rb +++ b/app/models/security_requirements_guide.rb @@ -17,6 +17,11 @@ class SecurityRequirementsGuide < ApplicationRecord scope: :version, message: ' ID has already been taken' } + # Length limits — configurable via Settings.input_limits (env vars: VULCAN_LIMIT_*) + validates :srg_id, :version, + length: { maximum: ->(_r) { Settings.input_limits.short_string } } + validates :title, length: { maximum: ->(_r) { Settings.input_limits.benchmark_title } } + validates :name, length: { maximum: ->(_r) { Settings.input_limits.benchmark_name } }, allow_nil: true # Since an SRG is top-level, the parameter is the entire parsed benchmark def self.from_mapping(benchmark_mapping) diff --git a/app/models/stig.rb b/app/models/stig.rb index 804cb5ade..96c883930 100644 --- a/app/models/stig.rb +++ b/app/models/stig.rb @@ -13,6 +13,12 @@ class Stig < ApplicationRecord scope: :version, message: 'ID has already been taken' } + # Length limits — configurable via Settings.input_limits (env vars: VULCAN_LIMIT_*) + validates :stig_id, :version, + length: { maximum: ->(_r) { Settings.input_limits.short_string } } + validates :title, length: { maximum: ->(_r) { Settings.input_limits.benchmark_title } } + validates :name, length: { maximum: ->(_r) { Settings.input_limits.benchmark_name } } + validates :description, length: { maximum: ->(_r) { Settings.input_limits.benchmark_description } }, allow_nil: true after_create :import_stig_rules # STIG parameter is the entire parsed benchmark diff --git a/app/models/user.rb b/app/models/user.rb index d0e52e75c..6d446f19e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -23,7 +23,9 @@ class ProviderConflictError < StandardError; end devise :omniauthable, omniauth_providers: Devise.omniauth_providers - validates :name, presence: true + validates :name, presence: true, + length: { maximum: ->(_r) { Settings.input_limits.user_name } } + validates :email, length: { maximum: ->(_r) { Settings.input_limits.user_email } }, allow_nil: true # AC-10: Skip session limiting when disabled via settings def skip_session_limitable? diff --git a/config/vulcan.default.yml b/config/vulcan.default.yml index 5b58e79ea..2fa737383 100644 --- a/config/vulcan.default.yml +++ b/config/vulcan.default.yml @@ -110,6 +110,28 @@ defaults: &defaults min_lowercase: <%= ENV.fetch('VULCAN_PASSWORD_MIN_LOWERCASE', '2') %> min_number: <%= ENV.fetch('VULCAN_PASSWORD_MIN_NUMBER', '2') %> min_special: <%= ENV.fetch('VULCAN_PASSWORD_MIN_SPECIAL', '2') %> + input_limits: + # Maximum lengths for text fields, grouped by category. + # Based on analysis of real DISA STIG/SRG data (1,785 rules across 8 benchmarks). + # Admins can tune via environment variables per deployment. + short_string: <%= ENV.fetch('VULCAN_LIMIT_SHORT_STRING', '255').to_i %> + ident: <%= ENV.fetch('VULCAN_LIMIT_IDENT', '2048').to_i %> + title: <%= ENV.fetch('VULCAN_LIMIT_TITLE', '500').to_i %> + medium_text: <%= ENV.fetch('VULCAN_LIMIT_MEDIUM_TEXT', '1000').to_i %> + long_text: <%= ENV.fetch('VULCAN_LIMIT_LONG_TEXT', '10000').to_i %> + inspec_code: <%= ENV.fetch('VULCAN_LIMIT_INSPEC_CODE', '50000').to_i %> + component_name: <%= ENV.fetch('VULCAN_LIMIT_COMPONENT_NAME', '255').to_i %> + component_prefix: <%= ENV.fetch('VULCAN_LIMIT_COMPONENT_PREFIX', '10').to_i %> + component_title: <%= ENV.fetch('VULCAN_LIMIT_COMPONENT_TITLE', '500').to_i %> + component_description: <%= ENV.fetch('VULCAN_LIMIT_COMPONENT_DESCRIPTION', '5000').to_i %> + project_name: <%= ENV.fetch('VULCAN_LIMIT_PROJECT_NAME', '255').to_i %> + project_description: <%= ENV.fetch('VULCAN_LIMIT_PROJECT_DESCRIPTION', '5000').to_i %> + user_name: <%= ENV.fetch('VULCAN_LIMIT_USER_NAME', '255').to_i %> + user_email: <%= ENV.fetch('VULCAN_LIMIT_USER_EMAIL', '255').to_i %> + review_comment: <%= ENV.fetch('VULCAN_LIMIT_REVIEW_COMMENT', '10000').to_i %> + benchmark_name: <%= ENV.fetch('VULCAN_LIMIT_BENCHMARK_NAME', '500').to_i %> + benchmark_title: <%= ENV.fetch('VULCAN_LIMIT_BENCHMARK_TITLE', '500').to_i %> + benchmark_description: <%= ENV.fetch('VULCAN_LIMIT_BENCHMARK_DESCRIPTION', '10000').to_i %> slack: enabled: <%= ActiveModel::Type::Boolean.new.cast(ENV['VULCAN_ENABLE_SLACK_COMMS']) || false %> api_token: <%= ENV['VULCAN_SLACK_API_TOKEN'] %> diff --git a/docs/development/input-length-limits.md b/docs/development/input-length-limits.md index 54eac435b..e9ab38bdb 100644 --- a/docs/development/input-length-limits.md +++ b/docs/development/input-length-limits.md @@ -1,94 +1,139 @@ # Input Length Limits -All text fields in Vulcan enforce maximum lengths to prevent abuse and protect downstream consumers (Excel export, API responses, PDF generation). +All text fields in Vulcan enforce configurable maximum lengths via `Settings.input_limits`. +Limits are set in `config/vulcan.default.yml` with environment variable overrides so +administrators can tune per deployment. -## BaseRule Fields (base_rules table) +## Configuration -| Field | Type | Max Length | Rationale | +Limits are grouped by category. Each has a `VULCAN_LIMIT_*` environment variable: + +| Setting Key | Env Var | Default | Description | |---|---|---|---| -| `rule_id` | string | 255 | Identifier | -| `rule_weight` | string | 255 | Numeric string | -| `version` | string | 255 | Version identifier | -| `ident_system` | string | 255 | URI | -| `fixtext_fixref` | string | 255 | Reference ID | -| `fix_id` | string | 255 | Reference ID | -| `srg_id` | string | 255 | Identifier | -| `vuln_id` | string | 255 | Identifier | -| `legacy_ids` | string | 255 | Comma-separated IDs | -| `ident` | string | 2,048 | Comma-joined CCI list (real STIGs have 310+ chars) | -| `title` | text | 1,000 | Rule title | -| `status_justification` | text | 1,000 | Brief justification | -| `fixtext` | text | 10,000 | Fix instructions | -| `artifact_description` | text | 10,000 | Artifact details | -| `vendor_comments` | text | 10,000 | Vendor notes | -| `inspec_control_body` | text | 50,000 | InSpec Ruby code | -| `inspec_control_file` | text | 50,000 | InSpec file content | - -**Not length-validated** (constrained by inclusion validation): -- `rule_severity` — only `low`, `medium`, `high`, or empty string -- `status` — only values in `RuleConstants::STATUSES` - -## DisaRuleDescription Fields (disa_rule_descriptions table) - -| Field | Max Length | Rationale | +| `short_string` | `VULCAN_LIMIT_SHORT_STRING` | 255 | IDs, version strings, reference fields | +| `ident` | `VULCAN_LIMIT_IDENT` | 2,048 | Comma-joined CCI list (real max: 310) | +| `title` | `VULCAN_LIMIT_TITLE` | 500 | Rule titles (real max: 436) | +| `medium_text` | `VULCAN_LIMIT_MEDIUM_TEXT` | 1,000 | Status justification, brief text | +| `long_text` | `VULCAN_LIMIT_LONG_TEXT` | 10,000 | Descriptions, check content, fixtext (real max: 6,330) | +| `inspec_code` | `VULCAN_LIMIT_INSPEC_CODE` | 50,000 | InSpec control bodies (user-authored) | +| `component_name` | `VULCAN_LIMIT_COMPONENT_NAME` | 255 | Component name | +| `component_prefix` | `VULCAN_LIMIT_COMPONENT_PREFIX` | 10 | STIG ID prefix (e.g., ABCD-01) | +| `component_title` | `VULCAN_LIMIT_COMPONENT_TITLE` | 500 | Component title | +| `component_description` | `VULCAN_LIMIT_COMPONENT_DESCRIPTION` | 5,000 | Component description | +| `project_name` | `VULCAN_LIMIT_PROJECT_NAME` | 255 | Project name | +| `project_description` | `VULCAN_LIMIT_PROJECT_DESCRIPTION` | 5,000 | Project description | +| `user_name` | `VULCAN_LIMIT_USER_NAME` | 255 | User display name | +| `user_email` | `VULCAN_LIMIT_USER_EMAIL` | 255 | User email address | +| `review_comment` | `VULCAN_LIMIT_REVIEW_COMMENT` | 10,000 | Review comments | +| `benchmark_name` | `VULCAN_LIMIT_BENCHMARK_NAME` | 500 | SRG/STIG display name | +| `benchmark_title` | `VULCAN_LIMIT_BENCHMARK_TITLE` | 500 | SRG/STIG title | +| `benchmark_description` | `VULCAN_LIMIT_BENCHMARK_DESCRIPTION` | 10,000 | STIG description | + +## Field-to-Setting Mapping + +### BaseRule (base_rules table) + +| Field | Setting | Default | |---|---|---| -| `vuln_discussion` | 10,000 | DISA vulnerability discussion | -| `false_positives` | 10,000 | False positive notes | -| `false_negatives` | 10,000 | False negative notes | -| `mitigations` | 10,000 | Mitigation description | -| `severity_override_guidance` | 10,000 | Override guidance | -| `potential_impacts` | 10,000 | Impact description | -| `third_party_tools` | 10,000 | Tool references | -| `mitigation_control` | 10,000 | Control description | -| `responsibility` | 10,000 | Responsibility assignment | -| `ia_controls` | 10,000 | IA control references | -| `poam` | 10,000 | Plan of Action & Milestones | - -## Check Fields (checks table) - -| Field | Max Length | Rationale | +| `rule_id`, `rule_weight`, `version`, `ident_system`, `fixtext_fixref`, `fix_id`, `srg_id`, `vuln_id`, `legacy_ids` | `short_string` | 255 | +| `inspec_control_body_lang`, `inspec_control_file_lang` | `short_string` | 255 | +| `ident` | `ident` | 2,048 | +| `title` | `title` | 500 | +| `status_justification` | `medium_text` | 1,000 | +| `fixtext`, `artifact_description`, `vendor_comments` | `long_text` | 10,000 | +| `inspec_control_body`, `inspec_control_file` | `inspec_code` | 50,000 | +| `rule_severity` | N/A — constrained by inclusion validation (low/medium/high) | +| `status` | N/A — constrained by inclusion validation | + +### DisaRuleDescription (disa_rule_descriptions table) + +| Field | Setting | Default | |---|---|---| -| `system` | 255 | Check system identifier | -| `content_ref_name` | 255 | Reference name | -| `content_ref_href` | 255 | Reference URL | -| `content` | 10,000 | Check content (verification steps) | +| `vuln_discussion`, `false_positives`, `false_negatives`, `mitigations`, `severity_override_guidance`, `potential_impacts`, `third_party_tools`, `mitigation_control`, `responsibility`, `ia_controls`, `poam` | `long_text` | 10,000 | -## Component Fields (components table) +### Check (checks table) -| Field | Max Length | Rationale | +| Field | Setting | Default | |---|---|---| -| `name` | 255 | Component name | -| `prefix` | 10 | STIG ID prefix | -| `title` | 500 | Component title | -| `description` | 5,000 | Component description | +| `system`, `content_ref_name`, `content_ref_href` | `short_string` | 255 | +| `content` | `long_text` | 10,000 | -## Project Fields (projects table) +### Component (components table) -| Field | Max Length | Rationale | +| Field | Setting | Default | |---|---|---| -| `name` | 255 | Project name | -| `description` | 5,000 | Project description | +| `name` | `component_name` | 255 | +| `prefix` | `component_prefix` | 10 | +| `title` | `component_title` | 500 | +| `description` | `component_description` | 5,000 | +| `admin_name`, `admin_email` | `short_string` | 255 | -## Upload Limits +### Project (projects table) -| Endpoint | Max Size | Allowed Types | +| Field | Setting | Default | |---|---|---| -| STIG upload (XML) | 50 MB | `.xml` | -| SRG upload (XML) | 50 MB | `.xml` | -| Spreadsheet import | 50 MB | `.xlsx`, `.csv` | -| JSON Archive import | 100 MB | `.zip` | +| `name` | `project_name` | 255 | +| `description` | `project_description` | 5,000 | +| `admin_name`, `admin_email` | `short_string` | 255 | + +### User (users table) + +| Field | Setting | Default | +|---|---|---| +| `name` | `user_name` | 255 | +| `email` | `user_email` | 255 | + +### SecurityRequirementsGuide (security_requirements_guides table) + +| Field | Setting | Default | +|---|---|---| +| `srg_id`, `version` | `short_string` | 255 | +| `title` | `benchmark_title` | 500 | +| `name` | `benchmark_name` | 500 | + +### Stig (stigs table) + +| Field | Setting | Default | +|---|---|---| +| `stig_id`, `version` | `short_string` | 255 | +| `title` | `benchmark_title` | 500 | +| `name` | `benchmark_name` | 500 | +| `description` | `benchmark_description` | 10,000 | + +### Review (reviews table) + +| Field | Setting | Default | +|---|---|---| +| `action` | `short_string` | 255 | +| `comment` | `review_comment` | 10,000 | + +## Real DISA Data Analysis + +Defaults based on analysis of 1,785 rules across 8 benchmarks (4 STIGs + 4 SRGs): + +| Field | Actual Max | P99 | Default Limit | Headroom | +|---|---|---|---|---| +| check.content | 6,330 | 1,888 | 10,000 | 37% | +| vuln_discussion | 3,813 | 2,125 | 10,000 | 62% | +| fixtext | 3,448 | 1,153 | 10,000 | 66% | +| title | 436 | 255 | 500 | 13% | +| ident | 310 | 70 | 2,048 | 85% | +| version | 25 | 25 | 255 | 90% | +| rule_id | 22 | 22 | 255 | 91% | ## Error Behavior When a length validation fails: -- **Direct model save**: `ActiveRecord::RecordInvalid` with message like "Title is too long (maximum is 1000 characters)" -- **STIG/SRG XML import**: Error includes rule ID and specific field: "3 rules failed to import: SV-12345: Title is too long (maximum is 1000 characters)" +- **Direct model save**: `ActiveRecord::RecordInvalid` with message like "Title is too long (maximum is 500 characters)" +- **STIG/SRG XML import**: Error includes rule ID and specific field: "3 rules failed to import: SV-12345: Title is too long (maximum is 500 characters)" - **Spreadsheet import**: Validation errors surface per-rule in the preview modal - **API responses**: 422 with `errors.full_messages` array -## Design Decisions +## Upload Limits -- Limits based on analysis of real DISA STIG/SRG content (RHEL 9 V2R7: max ident=310, vuln_discussion=2905, check_content=2367, fixtext=1756) -- `ident` gets 2,048 (not 255) because it's a comma-joined CCI list that grows with rule scope -- InSpec fields get 50,000 because generated control code can be substantial -- All limits use `allow_nil: true` to avoid breaking existing nil-valued records +| Endpoint | Max Size | Allowed Types | +|---|---|---| +| STIG upload (XML) | 50 MB | `.xml` | +| SRG upload (XML) | 50 MB | `.xml` | +| Spreadsheet import | 50 MB | `.xlsx`, `.csv` | +| JSON Archive import | 100 MB | `.zip` | From 4f00e155f592868f180f805f708de7ae9773a5b3 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 13:47:34 -0500 Subject: [PATCH 313/428] test: Settings-driven limit tests + 35 new configurable limits specs All validation tests now reference Settings.input_limits instead of hardcoded numbers. New configurable_limits_spec covers all 9 models with 35 tests verifying Settings integration. 78 total validation tests. Authored by: Aaron Lippold --- .../base_rule_length_validations_spec.rb | 33 +- spec/models/check_length_validations_spec.rb | 6 +- spec/models/configurable_limits_spec.rb | 373 ++++++++++++++++++ ...ule_description_length_validations_spec.rb | 5 +- spec/models/import_error_detail_spec.rb | 44 ++- 5 files changed, 423 insertions(+), 38 deletions(-) create mode 100644 spec/models/configurable_limits_spec.rb diff --git a/spec/models/base_rule_length_validations_spec.rb b/spec/models/base_rule_length_validations_spec.rb index d164a3061..a177252a8 100644 --- a/spec/models/base_rule_length_validations_spec.rb +++ b/spec/models/base_rule_length_validations_spec.rb @@ -8,34 +8,37 @@ # # Limits based on realistic STIG content maximums with headroom: # Short strings (IDs, names): 255 chars -# Medium text (title): 1_000 chars -# Long text (descriptions): 10_000 chars -# Very long (InSpec bodies): 50_000 chars +# Title: Settings.input_limits.title (default 500) +# Medium text (justification): Settings.input_limits.medium_text (default 1_000) +# Long text (descriptions): Settings.input_limits.long_text (default 10_000) +# Very long (InSpec bodies): Settings.input_limits.inspec_code (default 50_000) +# +# All limits are configurable via Settings.input_limits / VULCAN_LIMIT_* env vars. RSpec.describe BaseRule do describe 'text field length validations' do - # Short string fields (255) - # rule_severity excluded: constrained by inclusion validation (low/medium/high) + # Short string fields %i[rule_id rule_weight version ident_system fixtext_fixref fix_id srg_id vuln_id legacy_ids].each do |field| - it { is_expected.to validate_length_of(field).is_at_most(255).allow_nil } + it { is_expected.to validate_length_of(field).is_at_most(Settings.input_limits.short_string).allow_nil } end # ident is a comma-joined CCI list — real STIG data can have 310+ chars - it { is_expected.to validate_length_of(:ident).is_at_most(2_048).allow_nil } + it { is_expected.to validate_length_of(:ident).is_at_most(Settings.input_limits.ident).allow_nil } - # Medium text (1_000) - %i[title status_justification].each do |field| - it { is_expected.to validate_length_of(field).is_at_most(1_000).allow_nil } - end + # Title + it { is_expected.to validate_length_of(:title).is_at_most(Settings.input_limits.title).allow_nil } + + # Medium text + it { is_expected.to validate_length_of(:status_justification).is_at_most(Settings.input_limits.medium_text).allow_nil } - # Long text (10_000) + # Long text %i[fixtext artifact_description vendor_comments].each do |field| - it { is_expected.to validate_length_of(field).is_at_most(10_000).allow_nil } + it { is_expected.to validate_length_of(field).is_at_most(Settings.input_limits.long_text).allow_nil } end - # Very long text (50_000) + # Very long text (InSpec code) %i[inspec_control_body inspec_control_file].each do |field| - it { is_expected.to validate_length_of(field).is_at_most(50_000).allow_nil } + it { is_expected.to validate_length_of(field).is_at_most(Settings.input_limits.inspec_code).allow_nil } end end end diff --git a/spec/models/check_length_validations_spec.rb b/spec/models/check_length_validations_spec.rb index 6a65adb5d..a1b19b024 100644 --- a/spec/models/check_length_validations_spec.rb +++ b/spec/models/check_length_validations_spec.rb @@ -3,13 +3,13 @@ require 'rails_helper' # REQUIREMENT: Check content (check_content in exports) can be lengthy but must -# have an upper bound. +# have an upper bound. All limits read from Settings.input_limits for configurability. RSpec.describe Check do describe 'text field length validations' do %i[system content_ref_name content_ref_href].each do |field| - it { is_expected.to validate_length_of(field).is_at_most(255).allow_nil } + it { is_expected.to validate_length_of(field).is_at_most(Settings.input_limits.short_string).allow_nil } end - it { is_expected.to validate_length_of(:content).is_at_most(10_000).allow_nil } + it { is_expected.to validate_length_of(:content).is_at_most(Settings.input_limits.long_text).allow_nil } end end diff --git a/spec/models/configurable_limits_spec.rb b/spec/models/configurable_limits_spec.rb new file mode 100644 index 000000000..4f2760ead --- /dev/null +++ b/spec/models/configurable_limits_spec.rb @@ -0,0 +1,373 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENT: Input length limits must be configurable via Settings so that +# Vulcan administrators can tune limits per deployment via environment variables. +# Models must read from Settings.input_limits, NOT use hardcoded numbers. +# +# Default limits (based on real DISA STIG/SRG data analysis across 1,785 rules): +# short_string: 255 (real max: 25 — IDs, version strings) +# ident: 2048 (real max: 310 — comma-joined CCI list) +# title: 500 (real max: 436 — rule titles) +# medium_text: 1000 (status justification, brief text) +# long_text: 10000 (real max: 6330 — vuln_discussion, check_content, fixtext) +# inspec_code: 50000 (user-authored InSpec control bodies) +# component_name: 255 +# component_prefix: 10 +# component_title: 500 +# component_description: 5000 +# project_name: 255 +# project_description: 5000 +RSpec.describe 'Configurable input length limits' do + describe 'Settings.input_limits' do + it 'provides default values for rule field limits' do + limits = Settings.input_limits + + expect(limits.short_string).to eq(255) + expect(limits.ident).to eq(2048) + expect(limits.title).to eq(500) + expect(limits.medium_text).to eq(1000) + expect(limits.long_text).to eq(10_000) + expect(limits.inspec_code).to eq(50_000) + end + + it 'provides default values for entity and benchmark limits' do + limits = Settings.input_limits + + expect(limits.component_name).to eq(255) + expect(limits.component_prefix).to eq(10) + expect(limits.component_title).to eq(500) + expect(limits.component_description).to eq(5000) + expect(limits.project_name).to eq(255) + expect(limits.project_description).to eq(5000) + expect(limits.user_name).to eq(255) + expect(limits.user_email).to eq(255) + expect(limits.review_comment).to eq(10_000) + expect(limits.benchmark_name).to eq(500) + expect(limits.benchmark_title).to eq(500) + expect(limits.benchmark_description).to eq(10_000) + end + end + + describe 'BaseRule reads limits from Settings' do + it 'uses Settings.input_limits.short_string for ID fields' do + rule = BaseRule.new + rule.rule_id = 'x' * (Settings.input_limits.short_string + 1) + rule.valid? + expect(rule.errors[:rule_id]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.ident for ident field' do + rule = BaseRule.new + rule.ident = 'x' * (Settings.input_limits.ident + 1) + rule.valid? + expect(rule.errors[:ident]).to include( + "is too long (maximum is #{Settings.input_limits.ident} characters)" + ) + end + + it 'uses Settings.input_limits.title for title field' do + rule = BaseRule.new + rule.title = 'x' * (Settings.input_limits.title + 1) + rule.valid? + expect(rule.errors[:title]).to include( + "is too long (maximum is #{Settings.input_limits.title} characters)" + ) + end + + it 'uses Settings.input_limits.long_text for fixtext field' do + rule = BaseRule.new + rule.fixtext = 'x' * (Settings.input_limits.long_text + 1) + rule.valid? + expect(rule.errors[:fixtext]).to include( + "is too long (maximum is #{Settings.input_limits.long_text} characters)" + ) + end + + it 'uses Settings.input_limits.medium_text for status_justification' do + rule = BaseRule.new + rule.status_justification = 'x' * (Settings.input_limits.medium_text + 1) + rule.valid? + expect(rule.errors[:status_justification]).to include( + "is too long (maximum is #{Settings.input_limits.medium_text} characters)" + ) + end + + it 'uses Settings.input_limits.inspec_code for inspec_control_body' do + rule = BaseRule.new + rule.inspec_control_body = 'x' * (Settings.input_limits.inspec_code + 1) + rule.valid? + expect(rule.errors[:inspec_control_body]).to include( + "is too long (maximum is #{Settings.input_limits.inspec_code} characters)" + ) + end + end + + describe 'DisaRuleDescription reads limits from Settings' do + it 'uses Settings.input_limits.long_text for vuln_discussion' do + desc = DisaRuleDescription.new + desc.vuln_discussion = 'x' * (Settings.input_limits.long_text + 1) + desc.valid? + expect(desc.errors[:vuln_discussion]).to include( + "is too long (maximum is #{Settings.input_limits.long_text} characters)" + ) + end + end + + describe 'Check reads limits from Settings' do + it 'uses Settings.input_limits.short_string for system field' do + check = Check.new + check.system = 'x' * (Settings.input_limits.short_string + 1) + check.valid? + expect(check.errors[:system]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.long_text for content field' do + check = Check.new + check.content = 'x' * (Settings.input_limits.long_text + 1) + check.valid? + expect(check.errors[:content]).to include( + "is too long (maximum is #{Settings.input_limits.long_text} characters)" + ) + end + end + + describe 'Component reads limits from Settings' do + it 'uses Settings.input_limits.component_name for name' do + component = Component.new + component.name = 'x' * (Settings.input_limits.component_name + 1) + component.valid? + expect(component.errors[:name]).to include( + "is too long (maximum is #{Settings.input_limits.component_name} characters)" + ) + end + + it 'uses Settings.input_limits.component_title for title' do + component = Component.new + component.title = 'x' * (Settings.input_limits.component_title + 1) + component.valid? + expect(component.errors[:title]).to include( + "is too long (maximum is #{Settings.input_limits.component_title} characters)" + ) + end + + it 'uses Settings.input_limits.component_description for description' do + component = Component.new + component.description = 'x' * (Settings.input_limits.component_description + 1) + component.valid? + expect(component.errors[:description]).to include( + "is too long (maximum is #{Settings.input_limits.component_description} characters)" + ) + end + end + + describe 'Project reads limits from Settings' do + it 'uses Settings.input_limits.project_name for name' do + project = Project.new + project.name = 'x' * (Settings.input_limits.project_name + 1) + project.valid? + expect(project.errors[:name]).to include( + "is too long (maximum is #{Settings.input_limits.project_name} characters)" + ) + end + + it 'uses Settings.input_limits.project_description for description' do + project = Project.new + project.description = 'x' * (Settings.input_limits.project_description + 1) + project.valid? + expect(project.errors[:description]).to include( + "is too long (maximum is #{Settings.input_limits.project_description} characters)" + ) + end + end + + describe 'BaseRule covers language fields' do + it 'uses Settings.input_limits.short_string for inspec_control_body_lang' do + rule = BaseRule.new + rule.inspec_control_body_lang = 'x' * (Settings.input_limits.short_string + 1) + rule.valid? + expect(rule.errors[:inspec_control_body_lang]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.short_string for inspec_control_file_lang' do + rule = BaseRule.new + rule.inspec_control_file_lang = 'x' * (Settings.input_limits.short_string + 1) + rule.valid? + expect(rule.errors[:inspec_control_file_lang]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + end + + describe 'User reads limits from Settings' do + it 'uses Settings.input_limits.user_name for name' do + user = User.new + user.name = 'x' * (Settings.input_limits.user_name + 1) + user.valid? + expect(user.errors[:name]).to include( + "is too long (maximum is #{Settings.input_limits.user_name} characters)" + ) + end + + it 'uses Settings.input_limits.user_email for email' do + user = User.new + user.email = "#{'x' * Settings.input_limits.user_email}@example.com" + user.valid? + expect(user.errors[:email]).to include( + "is too long (maximum is #{Settings.input_limits.user_email} characters)" + ) + end + end + + describe 'Review reads limits from Settings' do + it 'uses Settings.input_limits.short_string for action' do + review = Review.new + review.action = 'x' * (Settings.input_limits.short_string + 1) + review.valid? + expect(review.errors[:action]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.review_comment for comment' do + review = Review.new + review.comment = 'x' * (Settings.input_limits.review_comment + 1) + review.valid? + expect(review.errors[:comment]).to include( + "is too long (maximum is #{Settings.input_limits.review_comment} characters)" + ) + end + end + + describe 'SecurityRequirementsGuide reads limits from Settings' do + it 'uses Settings.input_limits.short_string for srg_id' do + srg = SecurityRequirementsGuide.new + srg.srg_id = 'x' * (Settings.input_limits.short_string + 1) + srg.valid? + expect(srg.errors[:srg_id]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.benchmark_title for title' do + srg = SecurityRequirementsGuide.new + srg.title = 'x' * (Settings.input_limits.benchmark_title + 1) + srg.valid? + expect(srg.errors[:title]).to include( + "is too long (maximum is #{Settings.input_limits.benchmark_title} characters)" + ) + end + + it 'uses Settings.input_limits.short_string for version' do + srg = SecurityRequirementsGuide.new + srg.version = 'x' * (Settings.input_limits.short_string + 1) + srg.valid? + expect(srg.errors[:version]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.benchmark_name for name' do + srg = SecurityRequirementsGuide.new + srg.name = 'x' * (Settings.input_limits.benchmark_name + 1) + srg.valid? + expect(srg.errors[:name]).to include( + "is too long (maximum is #{Settings.input_limits.benchmark_name} characters)" + ) + end + end + + describe 'Stig reads limits from Settings' do + it 'uses Settings.input_limits.short_string for stig_id' do + stig = Stig.new + stig.stig_id = 'x' * (Settings.input_limits.short_string + 1) + stig.valid? + expect(stig.errors[:stig_id]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.benchmark_title for title' do + stig = Stig.new + stig.title = 'x' * (Settings.input_limits.benchmark_title + 1) + stig.valid? + expect(stig.errors[:title]).to include( + "is too long (maximum is #{Settings.input_limits.benchmark_title} characters)" + ) + end + + it 'uses Settings.input_limits.benchmark_description for description' do + stig = Stig.new + stig.description = 'x' * (Settings.input_limits.benchmark_description + 1) + stig.valid? + expect(stig.errors[:description]).to include( + "is too long (maximum is #{Settings.input_limits.benchmark_description} characters)" + ) + end + + it 'uses Settings.input_limits.short_string for version' do + stig = Stig.new + stig.version = 'x' * (Settings.input_limits.short_string + 1) + stig.valid? + expect(stig.errors[:version]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.benchmark_name for name' do + stig = Stig.new + stig.name = 'x' * (Settings.input_limits.benchmark_name + 1) + stig.valid? + expect(stig.errors[:name]).to include( + "is too long (maximum is #{Settings.input_limits.benchmark_name} characters)" + ) + end + end + + describe 'Component covers admin fields' do + it 'uses Settings.input_limits.short_string for admin_name' do + component = Component.new + component.admin_name = 'x' * (Settings.input_limits.short_string + 1) + component.valid? + expect(component.errors[:admin_name]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.short_string for admin_email' do + component = Component.new + component.admin_email = 'x' * (Settings.input_limits.short_string + 1) + component.valid? + expect(component.errors[:admin_email]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + end + + describe 'Project covers admin fields' do + it 'uses Settings.input_limits.short_string for admin_name' do + project = Project.new + project.admin_name = 'x' * (Settings.input_limits.short_string + 1) + project.valid? + expect(project.errors[:admin_name]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + + it 'uses Settings.input_limits.short_string for admin_email' do + project = Project.new + project.admin_email = 'x' * (Settings.input_limits.short_string + 1) + project.valid? + expect(project.errors[:admin_email]).to include( + "is too long (maximum is #{Settings.input_limits.short_string} characters)" + ) + end + end +end diff --git a/spec/models/disa_rule_description_length_validations_spec.rb b/spec/models/disa_rule_description_length_validations_spec.rb index 5a2332a9a..b6fcda8af 100644 --- a/spec/models/disa_rule_description_length_validations_spec.rb +++ b/spec/models/disa_rule_description_length_validations_spec.rb @@ -3,14 +3,13 @@ require 'rails_helper' # REQUIREMENT: Text fields on disa_rule_descriptions must enforce maximum lengths. -# vuln_discussion can be lengthy (DISA content), others are shorter metadata. +# All limits read from Settings.input_limits for configurability. RSpec.describe DisaRuleDescription do describe 'text field length validations' do - # Long text (10_000) %i[vuln_discussion false_positives false_negatives mitigations severity_override_guidance potential_impacts third_party_tools mitigation_control responsibility ia_controls poam].each do |field| - it { is_expected.to validate_length_of(field).is_at_most(10_000).allow_nil } + it { is_expected.to validate_length_of(field).is_at_most(Settings.input_limits.long_text).allow_nil } end end end diff --git a/spec/models/import_error_detail_spec.rb b/spec/models/import_error_detail_spec.rb index ee051115d..1dcbebc18 100644 --- a/spec/models/import_error_detail_spec.rb +++ b/spec/models/import_error_detail_spec.rb @@ -4,38 +4,43 @@ # REQUIREMENT: Validation error messages must include the specific field name # and limit so users can fix their data. No generic "failed to import" messages. +# All limits reference Settings.input_limits for configurability. RSpec.describe 'Input length validation error messages' do describe 'BaseRule' do # Use StigRule as concrete subclass (BaseRule is abstract via STI) let(:rule) { StigRule.new(rule_id: 'TEST-001', status: 'Not Yet Determined', rule_severity: 'medium') } + let(:title_limit) { Settings.input_limits.title } + let(:long_text_limit) { Settings.input_limits.long_text } + let(:ident_limit) { Settings.input_limits.ident } + let(:inspec_limit) { Settings.input_limits.inspec_code } it 'reports field name and max for title' do - rule.title = 'x' * 1001 + rule.title = 'x' * (title_limit + 1) rule.valid? - expect(rule.errors.full_messages).to include('Title is too long (maximum is 1000 characters)') + expect(rule.errors.full_messages).to include("Title is too long (maximum is #{title_limit} characters)") end it 'reports field name and max for fixtext' do - rule.fixtext = 'x' * 10_001 + rule.fixtext = 'x' * (long_text_limit + 1) rule.valid? - expect(rule.errors.full_messages).to include('Fixtext is too long (maximum is 10000 characters)') + expect(rule.errors.full_messages).to include("Fixtext is too long (maximum is #{long_text_limit} characters)") end it 'reports field name and max for ident' do - rule.ident = 'C' * 2049 + rule.ident = 'C' * (ident_limit + 1) rule.valid? - expect(rule.errors.full_messages).to include('Ident is too long (maximum is 2048 characters)') + expect(rule.errors.full_messages).to include("Ident is too long (maximum is #{ident_limit} characters)") end it 'reports field name and max for inspec_control_body' do - rule.inspec_control_body = 'x' * 50_001 + rule.inspec_control_body = 'x' * (inspec_limit + 1) rule.valid? - expect(rule.errors.full_messages).to include('Inspec control body is too long (maximum is 50000 characters)') + expect(rule.errors.full_messages).to include("Inspec control body is too long (maximum is #{inspec_limit} characters)") end it 'allows values at exactly the limit' do - rule.title = 'x' * 1000 - rule.fixtext = 'x' * 10_000 + rule.title = 'x' * title_limit + rule.fixtext = 'x' * long_text_limit rule.ident = 'CCI-000001' rule.valid? expect(rule.errors[:title]).to be_empty @@ -45,30 +50,35 @@ end describe 'DisaRuleDescription' do + let(:long_text_limit) { Settings.input_limits.long_text } + it 'reports field name and max for vuln_discussion' do - desc = DisaRuleDescription.new(vuln_discussion: 'x' * 10_001) + desc = DisaRuleDescription.new(vuln_discussion: 'x' * (long_text_limit + 1)) desc.valid? - expect(desc.errors.full_messages).to include('Vuln discussion is too long (maximum is 10000 characters)') + expect(desc.errors.full_messages).to include("Vuln discussion is too long (maximum is #{long_text_limit} characters)") end it 'allows values at exactly the limit' do - desc = DisaRuleDescription.new(vuln_discussion: 'x' * 10_000) + desc = DisaRuleDescription.new(vuln_discussion: 'x' * long_text_limit) desc.valid? expect(desc.errors[:vuln_discussion]).to be_empty end end describe 'Check' do + let(:long_text_limit) { Settings.input_limits.long_text } + let(:short_string_limit) { Settings.input_limits.short_string } + it 'reports field name and max for content' do - check = Check.new(content: 'x' * 10_001) + check = Check.new(content: 'x' * (long_text_limit + 1)) check.valid? - expect(check.errors.full_messages).to include('Content is too long (maximum is 10000 characters)') + expect(check.errors.full_messages).to include("Content is too long (maximum is #{long_text_limit} characters)") end it 'reports field name and max for system' do - check = Check.new(system: 'x' * 256) + check = Check.new(system: 'x' * (short_string_limit + 1)) check.valid? - expect(check.errors.full_messages).to include('System is too long (maximum is 255 characters)') + expect(check.errors.full_messages).to include("System is too long (maximum is #{short_string_limit} characters)") end end From 6acd1b1b04594f5abb8e2d9fb6a747f67c37e843 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 13:47:54 -0500 Subject: [PATCH 314/428] fix: CSP unsafe-eval for Vue 2 + rack-attack flaky test isolation - Add unsafe-eval to script-src (Vue 2 full build needs new Function() for runtime template compilation in HAML views) - Fix intermittent rack-attack test failure by using unique random emails/IPs per test run and properly isolating cache store Authored by: Aaron Lippold --- .../initializers/content_security_policy.rb | 6 +++- spec/requests/rack_attack_spec.rb | 34 +++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index ff5696682..65316cd23 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -5,6 +5,10 @@ # # Vulcan bundles all JS/CSS locally via esbuild (no CDN dependencies). # Vue 2 + BootstrapVue use inline styles, so style-src requires 'unsafe-inline'. +# Vue 2 full build (vue.esm.js) compiles templates at runtime using new Function(), +# which requires 'unsafe-eval' in script-src. This is a known Vue 2 limitation +# when templates are embedded in HAML views. Can be removed after Vue 3 migration +# (which uses pre-compiled SFC templates via build step). Rails.application.configure do config.content_security_policy do |policy| @@ -12,7 +16,7 @@ policy.font_src :self, :data policy.img_src :self, :data policy.object_src :none - policy.script_src :self + policy.script_src :self, :unsafe_eval policy.style_src :self, :unsafe_inline policy.connect_src :self policy.frame_src :none diff --git a/spec/requests/rack_attack_spec.rb b/spec/requests/rack_attack_spec.rb index e1dee0ff0..b11cc7877 100644 --- a/spec/requests/rack_attack_spec.rb +++ b/spec/requests/rack_attack_spec.rb @@ -11,39 +11,53 @@ RSpec.describe 'Rack::Attack throttling' do before do Rails.application.reload_routes! - # Clear rack-attack cache between tests - Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + # Create a fresh, isolated cache store for each test to prevent + # cross-contamination from other specs in the same parallel worker + @fresh_store = ActiveSupport::Cache::MemoryStore.new + Rack::Attack.cache.store = @fresh_store + Rack::Attack.reset! + end + + after do + # Restore default cache and clear to prevent bleeding into other tests + Rack::Attack.cache.store = Rails.cache Rack::Attack.reset! end describe 'login throttling' do it 'allows 5 login attempts then returns 429' do + # Use unique IP per test run to avoid cross-test contamination + test_ip = "192.168.#{rand(1..254)}.#{rand(1..254)}" + 5.times do |i| post '/users/sign_in', - params: { user: { email: "test#{i}@example.com", password: 'wrong' } }, - headers: { 'REMOTE_ADDR' => '1.2.3.4' } + params: { user: { email: "throttle-ip-#{i}-#{SecureRandom.hex(4)}@example.com", password: 'wrong' } }, + headers: { 'REMOTE_ADDR' => test_ip } expect(response.status).not_to eq(429), "Request #{i + 1} was throttled unexpectedly" end # 6th attempt should be throttled post '/users/sign_in', - params: { user: { email: 'test@example.com', password: 'wrong' } }, - headers: { 'REMOTE_ADDR' => '1.2.3.4' } + params: { user: { email: "throttle-ip-final-#{SecureRandom.hex(4)}@example.com", password: 'wrong' } }, + headers: { 'REMOTE_ADDR' => test_ip } expect(response).to have_http_status(:too_many_requests) expect(response.parsed_body.dig('toast', 'title')).to eq('Rate limited') end it 'throttles by email independently of IP' do + # Use unique email per test run to avoid cross-test contamination + target_email = "throttle-email-#{SecureRandom.hex(6)}@example.com" + 5.times do |i| post '/users/sign_in', - params: { user: { email: 'target@example.com', password: 'wrong' } }, - headers: { 'REMOTE_ADDR' => "10.0.0.#{i + 1}" } + params: { user: { email: target_email, password: 'wrong' } }, + headers: { 'REMOTE_ADDR' => "172.16.#{rand(1..254)}.#{i + 1}" } end # 6th attempt with same email from different IP should be throttled post '/users/sign_in', - params: { user: { email: 'target@example.com', password: 'wrong' } }, - headers: { 'REMOTE_ADDR' => '10.0.0.99' } + params: { user: { email: target_email, password: 'wrong' } }, + headers: { 'REMOTE_ADDR' => "172.16.#{rand(1..254)}.99" } expect(response).to have_http_status(:too_many_requests) end end From 7b38b17e355f108418469a00e820d6cafaf4c01d Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 14:53:00 -0500 Subject: [PATCH 315/428] fix: correct stale claims in architecture docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vue instance count: "16 separate" → "14 Vue instances + base + toaster" - Password hashing: bcrypt → PBKDF2-SHA512 (migrated in v2.3.1) - Remove false CDN, APM, rollbar/sentry, StatsD claims - Add actual monitoring: health checks, structured logging, rack-attack - Update export pipeline: service-based modes, caxlsx, spreadsheet reimport Authored by: Aaron Lippold --- docs/development/architecture.md | 47 ++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 85dbcb0d5..cd9c94230 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -53,7 +53,7 @@ vulcan/ ## Vue.js Architecture -Vulcan uses a unique approach with 16 separate Vue instances, one per page: +Vulcan uses 16 JavaScript entry points — 14 Vue instances (one per page), a base application setup, and a global notification component: ```javascript // Each pack file creates its own Vue instance @@ -148,7 +148,7 @@ Permission hierarchy: `admin > author > reviewer > viewer`, scoped to Project or See [Authorization Architecture](authorization.md) for details. ### Data Protection -- Passwords hashed with bcrypt +- Passwords hashed with PBKDF2-SHA512 (migrated from bcrypt in v2.3.1) - API tokens stored encrypted - SSL/TLS required in production - Sensitive data filtered from logs @@ -157,16 +157,15 @@ See [Authorization Architecture](authorization.md) for details. ### Optimizations - Database query optimization with includes/joins -- Fragment caching for expensive views +- Jbuilder collection caching for JSON views +- Composite indexes for severity count queries - Turbolinks for faster page transitions -- CDN for static assets -- Docker multi-stage builds (1.76GB image) +- Docker multi-stage builds with jemalloc for memory efficiency ### Monitoring -- Application Performance Monitoring (APM) -- Error tracking with rollbar/sentry -- Custom metrics via StatsD -- Health check endpoints +- Health check endpoints (`/up`, `/health_check`, `/health_check/database`, `/health_check/migrations`) +- Container-friendly logging (`RAILS_LOG_TO_STDOUT`, optional `STRUCTURED_LOGGING` for JSON output) +- Rack::Attack rate limiting on login and file upload endpoints ## Deployment Architecture @@ -207,22 +206,42 @@ FROM ruby:3.4.8-slim 2. Validates required headers against `ImportConstants::REQUIRED_MAPPING_CONSTANTS` 3. Maps columns to rule attributes, converts severity (`CAT I/II/III` → `high/medium/low`) +**Spreadsheet Re-import (Update from Spreadsheet):** +1. `POST /components/:id/update_from_spreadsheet` receives XLSX or CSV +2. `SpreadsheetParser` parses rows, matches rules by SRG ID +3. `Component#apply_spreadsheet_changes` compares fields using `Rule#field_editable?` +4. Only editable, changed fields are updated — locked sections are skipped +5. Returns a diff summary (changed rules, skipped fields, errors) + ### Export Pipeline +The export system uses a service-based architecture with modes and formatters. + +**Export modes** (project-level, purpose-first): + +| Mode | Formats | Description | +|------|---------|-------------| +| Working Copy | CSV, Excel | Internal review and bulk editing | +| Vendor Submission | Excel | 17-column strict DISA template | +| STIG-Ready Publish Draft | XCCDF, InSpec | Draft content for DISA review | +| Backup | JSON Archive | Full-fidelity project backup | + **Formats by entity:** -| Entity | XCCDF | CSV | InSpec | Excel | DISA Excel | -|--------|-------|-----|--------|-------|------------| +| Entity | XCCDF | CSV | InSpec | Excel | JSON Archive | +|--------|-------|-----|--------|-------|-------------| | Component | Yes | Yes | Yes | — | — | -| Project | Yes (ZIP) | — | Yes (ZIP) | Yes | Yes | +| Project | Yes (ZIP) | Yes | Yes (ZIP) | Yes | Yes | | STIG | Yes | Yes | — | — | — | | SRG | Yes | Yes | — | — | — | **Key files:** -- `app/helpers/export_helper.rb` — XCCDF, InSpec, and Excel export logic +- `app/services/export/` — service-based export pipeline (Registry, Base, modes, formatters) +- `app/services/export/formatters/excel_formatter.rb` — caxlsx-based Excel with locked sections +- `app/helpers/export_helper.rb` — legacy XCCDF/InSpec export (DISA format) - `app/constants/export_constants.rb` — column definitions, headers, defaults - `app/javascript/constants/csvColumns.js` — frontend column picker definitions -- `app/javascript/components/shared/ExportModal.vue` — reusable export UI +- `app/javascript/components/shared/ExportModal.vue` — mode-first export UI **CSV architecture:** - `BaseRule#csv_value_for(column_key)` maps 18 column keys to rule attribute values From cf71b26181873f1c5cc94ca096ffa1a51b56337d Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 14:53:07 -0500 Subject: [PATCH 316/428] chore: remove empty vue3-migration placeholder File had no content. Removed sidebar link. Vue 3 migration planning will live in v3.x branch when the time comes. Authored by: Aaron Lippold --- docs/.vitepress/config.js | 1 - docs/development/vue3-migration.md | 1 - 2 files changed, 2 deletions(-) delete mode 100644 docs/development/vue3-migration.md diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index e00a6e5b2..b0aa1f55a 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -196,7 +196,6 @@ export default defineConfig({ { text: "Section Locks", link: "/development/section-locks" }, { text: "Testing", link: "/development/testing" }, { text: "Release Process", link: "/development/release-process" }, - { text: "Vue 3 Migration", link: "/development/vue3-migration" }, ], }, { diff --git a/docs/development/vue3-migration.md b/docs/development/vue3-migration.md deleted file mode 100644 index 8b1378917..000000000 --- a/docs/development/vue3-migration.md +++ /dev/null @@ -1 +0,0 @@ - From b0011b778198abce32f65ac6124f776398454590 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 14:53:20 -0500 Subject: [PATCH 317/428] docs: sync all docs with v2.3.1 codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Update from Spreadsheet" round-trip editing to import-export guide - Add input length limits config section with 18 env vars - Update example vulcan.yml (add session_limits, input_limits, admin_bootstrap) - Fix bare-metal health check URL: /health → /up + /health_check - Fix Kubernetes ConfigMap session_timeout: 60 → "1h" - Update release notes: CSP headers, configurable limits, rack-attack fix Authored by: Aaron Lippold --- docs/deployment/bare-metal.md | 7 +- docs/deployment/kubernetes.md | 2 +- docs/getting-started/configuration.md | 68 +++++++++++++++++-- docs/release-notes/v2.3.1.md | 4 +- .../data-management/import-export.md | 28 ++++++++ 5 files changed, 100 insertions(+), 9 deletions(-) diff --git a/docs/deployment/bare-metal.md b/docs/deployment/bare-metal.md index 25d26bc4e..7102eeebd 100644 --- a/docs/deployment/bare-metal.md +++ b/docs/deployment/bare-metal.md @@ -381,8 +381,11 @@ Create `/etc/logrotate.d/vulcan`: ### Health Check Endpoint ```bash -# Add to monitoring system -curl https://vulcan.example.com/health +# Liveness check (no database) +curl https://vulcan.example.com/up + +# Readiness check (database connectivity) +curl https://vulcan.example.com/health_check ``` ### System Monitoring diff --git a/docs/deployment/kubernetes.md b/docs/deployment/kubernetes.md index 4d6bee58f..ad107f55e 100644 --- a/docs/deployment/kubernetes.md +++ b/docs/deployment/kubernetes.md @@ -99,7 +99,7 @@ data: local_login: enabled: true email_confirmation: false - session_timeout: 60 + session_timeout: "1h" # Accepts: 30s, 15m, 1h, or plain seconds (900) user_registration: enabled: true diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 264e7a600..4ea9a783b 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -374,9 +374,36 @@ VULCAN_PASSWORD_MIN_SPECIAL=0 Password complexity is only validated for local (email/password) accounts. OmniAuth users (OIDC, LDAP, GitHub) use random token passwords and skip complexity validation. ::: +## Configure Input Length Limits + +Configurable maximum lengths for all text input fields. Defaults are based on analysis of real DISA STIG/SRG data (1,785 rules across 8 benchmarks). Limits are grouped by category — each env var controls a category of related fields. + +See [Input Length Limits](../development/input-length-limits.md) for the complete field-to-setting mapping. + +| Variable | Description | Default | +|----------|-------------|---------| +| `VULCAN_LIMIT_SHORT_STRING` | IDs, version strings, reference fields | `255` | +| `VULCAN_LIMIT_IDENT` | Comma-joined CCI list | `2048` | +| `VULCAN_LIMIT_TITLE` | Rule titles | `500` | +| `VULCAN_LIMIT_MEDIUM_TEXT` | Status justification, brief text | `1000` | +| `VULCAN_LIMIT_LONG_TEXT` | Descriptions, check content, fixtext | `10000` | +| `VULCAN_LIMIT_INSPEC_CODE` | InSpec control bodies | `50000` | +| `VULCAN_LIMIT_COMPONENT_NAME` | Component name | `255` | +| `VULCAN_LIMIT_COMPONENT_PREFIX` | STIG ID prefix | `10` | +| `VULCAN_LIMIT_COMPONENT_TITLE` | Component title | `500` | +| `VULCAN_LIMIT_COMPONENT_DESCRIPTION` | Component description | `5000` | +| `VULCAN_LIMIT_PROJECT_NAME` | Project name | `255` | +| `VULCAN_LIMIT_PROJECT_DESCRIPTION` | Project description | `5000` | +| `VULCAN_LIMIT_USER_NAME` | User display name | `255` | +| `VULCAN_LIMIT_USER_EMAIL` | User email address | `255` | +| `VULCAN_LIMIT_REVIEW_COMMENT` | Review comments | `10000` | +| `VULCAN_LIMIT_BENCHMARK_NAME` | SRG/STIG display name | `500` | +| `VULCAN_LIMIT_BENCHMARK_TITLE` | SRG/STIG title | `500` | +| `VULCAN_LIMIT_BENCHMARK_DESCRIPTION` | STIG description | `10000` | + ## Example Vulcan.yml -``` +```yaml defaults: &defaults welcome_text: contact_email: @@ -396,6 +423,15 @@ defaults: &defaults local_login: enabled: email_confirmation: + session_timeout: + remember_me_enabled: + remember_me_duration: + user_registration: + enabled: + admin_bootstrap: + first_user_admin: + project: + create_permission_enabled: ldap: enabled: servers: @@ -409,15 +445,15 @@ defaults: &defaults password: base: oidc: - enabled: + enabled: strategy: title: args: - name: + name: scope: - uid_field: + uid_field: response_type: - issuer: + issuer: client_auth_method: client_signing_alg: nonce: @@ -443,6 +479,9 @@ defaults: &defaults version: title: content: + session_limits: + enabled: + max_sessions: lockout: enabled: maximum_attempts: @@ -455,6 +494,25 @@ defaults: &defaults min_lowercase: min_number: min_special: + input_limits: + short_string: + ident: + title: + medium_text: + long_text: + inspec_code: + component_name: + component_prefix: + component_title: + component_description: + project_name: + project_description: + user_name: + user_email: + review_comment: + benchmark_name: + benchmark_title: + benchmark_description: slack: enabled: api_token: diff --git a/docs/release-notes/v2.3.1.md b/docs/release-notes/v2.3.1.md index c8ee9550d..69789f99e 100644 --- a/docs/release-notes/v2.3.1.md +++ b/docs/release-notes/v2.3.1.md @@ -21,8 +21,10 @@ This release includes DISA process documentation, SRG ID display in satisfaction - XXE prevention: removed NOENT flag from XML parser, added NONET to block external entity expansion and network DTD fetching - Upload validation: file size limits (50 MB XML, 100 MB ZIP, 50 MB spreadsheet) and content-type checks on all upload endpoints - Rate limiting via rack-attack: login throttling (5/min/IP, 5/min/email), upload throttling (10/min/IP) -- Input length limits on Project and Component metadata fields +- Configurable input length limits on all text fields across 9 models (18 `VULCAN_LIMIT_*` env vars) +- Content Security Policy headers (script-src, style-src, frame-src, etc.) - HappyMapper NONET patch prevents SSRF via external DTD URIs in XCCDF parsing +- Rack-attack flaky test isolation (fresh cache per test, unique IPs/emails) ## Features diff --git a/docs/user-guide/data-management/import-export.md b/docs/user-guide/data-management/import-export.md index 30f7d9902..eb2cce6d2 100644 --- a/docs/user-guide/data-management/import-export.md +++ b/docs/user-guide/data-management/import-export.md @@ -51,6 +51,34 @@ Components can be imported from spreadsheets (`.xlsx` or `.csv`): The spreadsheet importer maps column headers to fields, validates SRG IDs against the selected SRG, and converts severity values (`CAT I/II/III` to `high/medium/low`). +### Update from Spreadsheet (Round-Trip Editing) + +Existing components can be bulk-edited via spreadsheet round-trip: + +1. **Export** the component as CSV or Excel (Working Copy mode) +2. **Edit** rules in Excel or Google Sheets +3. **Re-import** the edited spreadsheet to update the component + +**How to use:** + +1. Navigate to the Component page +2. Click **Update from Spreadsheet** in the command bar +3. Upload the edited `.xlsx` or `.csv` file +4. Review the **word-diff preview** showing exactly what changed per rule +5. Click **Apply Changes** to save, or **Cancel** to discard + +**Behavior:** + +- Rules are matched by SRG ID (the `SRG ID` column in the spreadsheet) +- Only **editable fields** are updated — section-locked fields are skipped +- A word-level diff is shown for each changed field before applying +- Satisfaction relationships are re-parsed from `vendor_comments` after update +- Rules not found in the spreadsheet are left unchanged (no deletions) + +::: warning Section Locks +If a rule has locked sections (e.g., check content locked by a reviewer), those fields will be skipped during import even if the spreadsheet contains different values. The preview will show these as "skipped (locked)." +::: + ### Satisfaction Relationships When a component is created or imported, Vulcan parses `vendor_comments` on each rule to detect satisfaction relationships between rules. From 60270c619ce9ac2aabb1bd6ed313221c676c7e33 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 15:06:26 -0500 Subject: [PATCH 318/428] chore: upgrade PostgreSQL from 12/16 to 18 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: postgres:12-alpine → postgres:18-alpine - CI workflow: postgres:16-alpine → postgres:18-alpine Authored by: Aaron Lippold --- .github/workflows/ci.yml | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab29d0f4..dc4941f56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,7 @@ jobs: services: db: - image: postgres:16-alpine + image: postgres:18-alpine env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres diff --git a/docker-compose.yml b/docker-compose.yml index 638e3b185..fc9856f46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: db: - image: postgres:12-alpine + image: postgres:18-alpine restart: unless-stopped volumes: - vulcan_dbdata:/var/lib/postgresql/data From 5d57fa4c3d38cc5194742c2cfcef22f1ba5ac2e6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 15:06:35 -0500 Subject: [PATCH 319/428] docs: sync env vars, fix CSV headers, add oidc keys to example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync VitePress env vars from root (adds banner, consent, password, input limits, remember-me sections) - Fix dead link: docs/development → ../development for VitePress - Fix CSV column headers to match ExportConstants (Description not Vuln Discussion, Check not Check Content, etc.) - Add missing oidc.discovery and oidc.prompt to example vulcan.yml Authored by: Aaron Lippold --- docs/getting-started/configuration.md | 2 + docs/getting-started/environment-variables.md | 105 +++++++++++++++++- .../data-management/import-export.md | 40 +++---- 3 files changed, 121 insertions(+), 26 deletions(-) diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 4ea9a783b..4d282d014 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -446,6 +446,7 @@ defaults: &defaults base: oidc: enabled: + discovery: strategy: title: args: @@ -457,6 +458,7 @@ defaults: &defaults client_auth_method: client_signing_alg: nonce: + prompt: client_options: port: scheme: diff --git a/docs/getting-started/environment-variables.md b/docs/getting-started/environment-variables.md index 9111db512..d59c5eda3 100644 --- a/docs/getting-started/environment-variables.md +++ b/docs/getting-started/environment-variables.md @@ -22,7 +22,15 @@ This document lists all environment variables that can be used to configure Vulc **Note:** `DATABASE_URL` takes precedence when set (recommended for Heroku, Kubernetes). Individual variables (`POSTGRES_USER`, `POSTGRES_PASSWORD`, etc.) are used as fallback. -**Worktree Isolation**: When developing with multiple git worktrees (e.g., v2.x and v3.x), set `DB_SUFFIX` in each worktree's `.env` to give each branch its own database. This prevents migration conflicts when branches have diverging schemas. Not needed in production. +**Worktree Isolation**: When developing with multiple git worktrees (e.g., v2.x and v3.x), set `DB_SUFFIX` in each worktree's `.env` to give each branch its own database. This prevents migration conflicts when branches have diverging schemas. Not needed in production — each deployment has its own database. + +```bash +# v2.x worktree .env +DB_SUFFIX=_v2 # → vulcan_vue_development_v2, vulcan_vue_test_v2 + +# v3.x worktree .env +DB_SUFFIX=_v3 # → vulcan_vue_development_v3, vulcan_vue_test_v3 +``` **Deprecated:** `VULCAN_VUE_DATABASE_PASSWORD` is deprecated. Use `POSTGRES_PASSWORD` instead. @@ -32,7 +40,7 @@ This document lists all environment variables that can be used to configure Vulc |----------|-------------|---------|---------| | `VULCAN_APP_URL` | Application URL | `http://localhost:3000` | `https://vulcan.example.com` | | `VULCAN_WELCOME_TEXT` | Welcome message on login page | `Welcome to Vulcan` | `Welcome to MITRE Vulcan` | -| `VULCAN_CONTACT_EMAIL` | Contact email for notifications | `do_not_reply@vulcan` | `admin@example.com` | +| `VULCAN_CONTACT_EMAIL` | Contact email for notifications and default SMTP username | `vulcan-support@example.com` | `support@mycompany.com` | ## Authentication Settings @@ -42,6 +50,8 @@ This document lists all environment variables that can be used to configure Vulc | `VULCAN_ENABLE_LOCAL_LOGIN` | Enable local username/password login | `true` | `true` or `false` | | `VULCAN_ENABLE_EMAIL_CONFIRMATION` | Require email confirmation for new users | `false` | `true` or `false` | | `VULCAN_SESSION_TIMEOUT` | Session inactivity timeout. Accepts explicit suffix (`30s`, `15m`, `1h`) or plain numbers (1-9 = hours, 10-299 = minutes, 300+ = seconds). | `1h` | `900` (DoD 15-min), `15m`, `1h` | +| `VULCAN_ENABLE_REMEMBER_ME` | Show "Remember Me" checkbox on login forms | `true` | `false` for DoD | +| `VULCAN_REMEMBER_ME_DURATION` | How long Remember Me keeps session alive. Same format as session timeout. | `8h` | `1d`, `28800` | ### User Registration | Variable | Description | Default | Example | @@ -76,6 +86,10 @@ Vulcan provides multiple ways to create the initial admin user. These are evalua **Docker Default**: In Docker deployments, `VULCAN_FIRST_USER_ADMIN=true` is the default, allowing immediate use after `docker compose up`. For production, disable this and use `VULCAN_ADMIN_EMAIL`. +**Security Note**: The first-user-admin feature uses PostgreSQL advisory locks to prevent race +condition attacks (similar to WordPress installer vulnerabilities). However, for production +deployments, explicit admin configuration via environment variables is recommended. + ### OIDC/OAuth (e.g., Okta, Auth0, Keycloak) **New in v2.2+**: Vulcan supports automatic endpoint discovery, reducing configuration from 8+ variables to just 4 essential ones. @@ -162,7 +176,7 @@ VULCAN_OIDC_REDIRECT_URI=https://vulcan.example.com/users/auth/oidc/callback | `VULCAN_SMTP_ADDRESS` | SMTP server address | - | `smtp.gmail.com` | | `VULCAN_SMTP_PORT` | SMTP server port | - | `587` | | `VULCAN_SMTP_DOMAIN` | SMTP domain | - | `example.com` | -| `VULCAN_SMTP_SERVER_USERNAME` | SMTP username | - | `notifications@example.com` | +| `VULCAN_SMTP_SERVER_USERNAME` | SMTP username (defaults to VULCAN_CONTACT_EMAIL if not set) | - | `notifications@example.com` | | `VULCAN_SMTP_SERVER_PASSWORD` | SMTP password | - | `smtp_password` | | `VULCAN_SMTP_AUTHENTICATION` | SMTP authentication method | - | `plain` | | `VULCAN_SMTP_OPENSSL_VERIFY_MODE` | OpenSSL verify mode for SMTP | - | `none` | @@ -177,15 +191,45 @@ VULCAN_OIDC_REDIRECT_URI=https://vulcan.example.com/users/auth/oidc/callback | `VULCAN_SLACK_API_TOKEN` | Slack API token | - | `xoxb-your-token` | | `VULCAN_SLACK_CHANNEL_ID` | Slack channel ID | - | `C1234567890` | -## Session Limits (STIG AC-10) +## Classification Banner + +Display a colored banner at the top and bottom of every page, commonly used for DoD classification markings. + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VULCAN_BANNER_ENABLED` | Enable classification banner | `false` | `true` | +| `VULCAN_BANNER_TEXT` | Banner text displayed on every page (plain text, no formatting) | `""` | `UNCLASSIFIED` | +| `VULCAN_BANNER_BACKGROUND_COLOR` | Banner background color (hex) | `#007a33` | `#c8102e` | +| `VULCAN_BANNER_TEXT_COLOR` | Banner text color (hex) | `#ffffff` | `#000000` | + +**DoD Standard Colors:** + +| Classification | Background | Text | +|---------------|------------|------| +| UNCLASSIFIED | `#007a33` | `#ffffff` | +| CUI | `#502b85` | `#ffffff` | +| CONFIDENTIAL | `#0033a0` | `#ffffff` | +| SECRET | `#c8102e` | `#ffffff` | +| TOP SECRET | `#ff671f` | `#ffffff` | +| TS/SCI | `#f7ea48` | `#000000` | + +## Consent / Terms of Use Modal + +Display a blocking consent modal that users must acknowledge before accessing the application. Acknowledgment is stored in the browser's localStorage per version — incrementing the version re-prompts all users. | Variable | Description | Default | Example | |----------|-------------|---------|---------| -| `VULCAN_SESSION_LIMITS_ENABLED` | Enable per-user concurrent session limits | `true` | `false` | -| `VULCAN_MAX_CONCURRENT_SESSIONS` | Maximum concurrent sessions per user. Oldest session evicted when exceeded. | `1` | `3` | +| `VULCAN_CONSENT_ENABLED` | Enable consent modal | `false` | `true` | +| `VULCAN_CONSENT_VERSION` | Version string for consent (increment to re-prompt) | `1` | `2` | +| `VULCAN_CONSENT_TITLE` | Modal title | `Terms of Use` | `Acceptable Use Policy` | +| `VULCAN_CONSENT_CONTENT` | Modal body content (supports **Markdown**) | `""` | `By using this system you agree to the **AUP**.` | + +**Consent Content Formatting**: The `VULCAN_CONSENT_CONTENT` variable supports full [Markdown](https://www.markdownguide.org/basic-syntax/) formatting including headings, bold, italics, numbered/bulleted lists, links, and blockquotes. HTML is sanitized for security. The banner text (`VULCAN_BANNER_TEXT`) is plain text only — no formatting is applied. ## Account Lockout (STIG AC-07) +Lock accounts after consecutive failed login attempts. Enabled by default with STIG AC-07 compliant settings. + | Variable | Description | Default | Example | |----------|-------------|---------|---------| | `VULCAN_LOCKOUT_ENABLED` | Enable account lockout | `true` | `false` | @@ -194,6 +238,55 @@ VULCAN_OIDC_REDIRECT_URI=https://vulcan.example.com/users/auth/oidc/callback | `VULCAN_LOCKOUT_UNLOCK_STRATEGY` | Unlock method: `email`, `time`, or `both` | `both` | `time` | | `VULCAN_LOCKOUT_LAST_ATTEMPT_WARNING` | Warn user on last attempt before lock | `true` | `false` | +**Unlock strategies:** +- `email` — sends an unlock link to the user's email (requires SMTP) +- `time` — automatically unlocks after `VULCAN_LOCKOUT_UNLOCK_IN_MINUTES` +- `both` — either method works (recommended, ensures unlock even without SMTP) + +Administrators can also manually unlock accounts from the Users page (`/users`). + +## Password Policy + +DoD-aligned defaults ("2222" policy). Set any count to `0` to disable that requirement. + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VULCAN_PASSWORD_MIN_LENGTH` | Minimum password length | `15` | `8` | +| `VULCAN_PASSWORD_MIN_UPPERCASE` | Minimum uppercase letters | `2` | `0` | +| `VULCAN_PASSWORD_MIN_LOWERCASE` | Minimum lowercase letters | `2` | `0` | +| `VULCAN_PASSWORD_MIN_NUMBER` | Minimum digits | `2` | `0` | +| `VULCAN_PASSWORD_MIN_SPECIAL` | Minimum special characters | `2` | `0` | + +## Input Length Limits + +Configurable maximum lengths for text fields. Defaults are based on analysis of real DISA STIG/SRG +data across 1,785 rules. Group limits by category rather than individual fields — each env var +controls a category of related fields. + +See [Input Length Limits](../development/input-length-limits.md) for the +complete field-to-setting mapping. + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VULCAN_LIMIT_SHORT_STRING` | IDs, version strings, reference fields | `255` | `512` | +| `VULCAN_LIMIT_IDENT` | Comma-joined CCI list (real max: 310) | `2048` | `4096` | +| `VULCAN_LIMIT_TITLE` | Rule titles (real max: 436) | `500` | `1000` | +| `VULCAN_LIMIT_MEDIUM_TEXT` | Status justification, brief text | `1000` | `2000` | +| `VULCAN_LIMIT_LONG_TEXT` | Descriptions, check content, fixtext (real max: 6,330) | `10000` | `20000` | +| `VULCAN_LIMIT_INSPEC_CODE` | InSpec control bodies (user-authored) | `50000` | `100000` | +| `VULCAN_LIMIT_COMPONENT_NAME` | Component name | `255` | `500` | +| `VULCAN_LIMIT_COMPONENT_PREFIX` | STIG ID prefix | `10` | `15` | +| `VULCAN_LIMIT_COMPONENT_TITLE` | Component title | `500` | `1000` | +| `VULCAN_LIMIT_COMPONENT_DESCRIPTION` | Component description | `5000` | `10000` | +| `VULCAN_LIMIT_PROJECT_NAME` | Project name | `255` | `500` | +| `VULCAN_LIMIT_PROJECT_DESCRIPTION` | Project description | `5000` | `10000` | +| `VULCAN_LIMIT_USER_NAME` | User display name | `255` | `500` | +| `VULCAN_LIMIT_USER_EMAIL` | User email address | `255` | `500` | +| `VULCAN_LIMIT_REVIEW_COMMENT` | Review comments | `10000` | `20000` | +| `VULCAN_LIMIT_BENCHMARK_NAME` | SRG/STIG display name | `500` | `1000` | +| `VULCAN_LIMIT_BENCHMARK_TITLE` | SRG/STIG title | `500` | `1000` | +| `VULCAN_LIMIT_BENCHMARK_DESCRIPTION` | STIG description | `10000` | `20000` | + ## Project Settings | Variable | Description | Default | Example | diff --git a/docs/user-guide/data-management/import-export.md b/docs/user-guide/data-management/import-export.md index eb2cce6d2..60673c4bf 100644 --- a/docs/user-guide/data-management/import-export.md +++ b/docs/user-guide/data-management/import-export.md @@ -197,26 +197,26 @@ InSpec exports create a ZIP archive containing: ### STIG Columns (18 available) -| Column | Header | Example | -|--------|--------|---------| -| `rule_id` | Rule ID | `SV-203591r557031_rule` | -| `version` | STIG ID | `RHEL-09-000001` | -| `srg_id` | SRG ID | `SRG-OS-000001-GPOS-00001` | -| `vuln_id` | Vuln ID | `V-203591` | -| `rule_severity` | Severity | `medium` | -| `title` | Title | `The system must...` | -| `vuln_discussion` | Vuln Discussion | `Without authentication...` | -| `check_content` | Check Content | `Verify the system...` | -| `fixtext` | Fix Text | `Configure the system...` | -| `ident` | CCI | `CCI-000068` | -| `nist_control_family` | NIST Control Family | `AC-17 (2)` | -| `legacy_ids` | Legacy IDs | `V-56571, SV-70831` | -| `status` | Status | `Applicable - Configurable` | -| `rule_weight` | Rule Weight | `10.0` | -| `mitigations` | Mitigations | | -| `severity_override_guidance` | Severity Override Guidance | | -| `false_positives` | False Positives | | -| `false_negatives` | False Negatives | | +| Column | Header | Default | Example | +|--------|--------|:-------:|---------| +| `rule_id` | Rule ID | ✅ | `SV-203591r557031_rule` | +| `version` | STIG ID | ✅ | `RHEL-09-000001` | +| `srg_id` | SRG ID | ✅ | `SRG-OS-000001-GPOS-00001` | +| `vuln_id` | Vuln ID | ✅ | `V-203591` | +| `rule_severity` | Severity | ✅ | `medium` | +| `title` | Title | ✅ | `The system must...` | +| `vuln_discussion` | Description | ✅ | `Without authentication...` | +| `check_content` | Check | ✅ | `Verify the system...` | +| `fixtext` | Fix | ✅ | `Configure the system...` | +| `ident` | CCI | ✅ | `CCI-000068` | +| `nist_control_family` | 800-53 Controls | ✅ | `AC-17 (2)` | +| `legacy_ids` | Legacy IDs | ✅ | `V-56571, SV-70831` | +| `status` | Status | | `Applicable - Configurable` | +| `rule_weight` | Weight | | `10.0` | +| `mitigations` | Mitigations | | | +| `severity_override_guidance` | Severity Override | | | +| `false_positives` | False Positives | | | +| `false_negatives` | False Negatives | | | ### SRG Columns (16 available) From 72da91b66631c8542e1efeea437cf170da860406 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 23 Feb 2026 15:06:42 -0500 Subject: [PATCH 320/428] docs: standardize PostgreSQL 18 across all docs - Update all PostgreSQL version references from 12+/15/16 to 18 - bare-metal, kubernetes, setup, testing, deployment index, homepage Authored by: Aaron Lippold --- docs/deployment/bare-metal.md | 2 +- docs/deployment/index.md | 2 +- docs/deployment/kubernetes.md | 2 +- docs/development/setup.md | 6 +++--- docs/development/testing.md | 2 +- docs/index.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/deployment/bare-metal.md b/docs/deployment/bare-metal.md index 7102eeebd..962267379 100644 --- a/docs/deployment/bare-metal.md +++ b/docs/deployment/bare-metal.md @@ -7,7 +7,7 @@ This guide covers deploying Vulcan directly on Linux servers without containeriz - Ubuntu 20.04+ or RHEL/CentOS 8+ server - Root or sudo access - Minimum 2GB RAM, 2 CPU cores -- PostgreSQL 12+ database server +- PostgreSQL 18 database server - Domain name with SSL certificate (recommended) ## System Preparation diff --git a/docs/deployment/index.md b/docs/deployment/index.md index 5e4037608..7b59f7a61 100644 --- a/docs/deployment/index.md +++ b/docs/deployment/index.md @@ -30,7 +30,7 @@ Choose the deployment method that fits your infrastructure and team. Every production deployment needs: 1. **Secrets** — `SECRET_KEY_BASE`, `CIPHER_PASSWORD`, `CIPHER_SALT` (generate with `openssl rand -hex 64`) -2. **PostgreSQL 12+** — dedicated database with secure password +2. **PostgreSQL 18** — dedicated database with secure password 3. **Authentication** — at least one provider (OIDC recommended, LDAP, or local login) 4. **SSL/TLS** — HTTPS for all production traffic diff --git a/docs/deployment/kubernetes.md b/docs/deployment/kubernetes.md index ad107f55e..ed16cf0dd 100644 --- a/docs/deployment/kubernetes.md +++ b/docs/deployment/kubernetes.md @@ -423,7 +423,7 @@ spec: spec: containers: - name: postgres-backup - image: postgres:15 + image: postgres:18 env: - name: PGPASSWORD valueFrom: diff --git a/docs/development/setup.md b/docs/development/setup.md index d29d4d83c..aee93c424 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -8,7 +8,7 @@ This guide walks through setting up a local Vulcan development environment. - **Ruby 3.4.8** (use rbenv or rvm for version management) - **Node.js 22 LTS** and **Yarn** package manager -- **PostgreSQL 12+** database server +- **PostgreSQL 18** database server - **Git** version control ### Recommended Tools @@ -117,8 +117,8 @@ rvm use 3.4.8@vulcan ```bash # macOS -brew install postgresql@16 -brew services start postgresql@16 +brew install postgresql@18 +brew services start postgresql@18 # Ubuntu/Debian sudo apt-get install postgresql postgresql-contrib diff --git a/docs/development/testing.md b/docs/development/testing.md index d4e6bfaab..04b19a3b9 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -434,7 +434,7 @@ jobs: services: postgres: - image: postgres:16 + image: postgres:18 env: POSTGRES_PASSWORD: postgres options: >- diff --git a/docs/index.md b/docs/index.md index 19da67fc0..09d4d005b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -89,7 +89,7 @@ Vulcan bridges the gap between security requirements and practical implementatio

    Backend

    • Ruby 3.4.8 with Rails 8.0.2.1
    • -
    • PostgreSQL 12+
    • +
    • PostgreSQL 18
    From 94e968715b3edd09577a2f248e6f80ba49f49724 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 28 Feb 2026 16:09:05 -0500 Subject: [PATCH 321/428] chore: Docker build infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip 11 app-config ENV vars from Dockerfile, keep 5 infra ENV - Add .dockerignore for docs/, downloads/, coverage/, dev files - Add source map cleanup in build stage - Use COPY --chown to avoid duplicate chown layer - Use SECRET_KEY_BASE_DUMMY=1 for build-time assets - Add build-base intermediate stage (DRY Node.js) - docker-bake.hcl: VERSION→latest, add BUNDLER_VERSION Image size: 1.29GB → 596MB Authored by: Aaron Lippold --- .dockerignore | 48 ++++++++++++++++++++++++++++ Dockerfile | 83 ++++++++++++++++--------------------------------- docker-bake.hcl | 26 +++++++++------- 3 files changed, 88 insertions(+), 69 deletions(-) diff --git a/.dockerignore b/.dockerignore index 02e8cdba2..8a75783a0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -48,3 +48,51 @@ # Ignore test files /spec + +# Ignore documentation site (has its own node_modules) +/docs + +# Ignore development downloads and test artifacts +/downloads +/coverage + +# Ignore development-only files +/.beads +/.overcommit.yml +/lefthook.yml +/.rubocop.yml +/.eslintrc.js +/.prettierrc +/vitest.config.js +/babel.config.js +/jsconfig.json +/sonar-project.properties +/postcss.config.js + +# Ignore non-Linux binaries +/bin/vulcan + +# Ignore compose and bake files (not needed inside image) +/docker-compose*.yml +/docker-bake.hcl +/Caddyfile* +/nginx.conf* +/setup-docker-secrets.sh + +# Ignore markdown files not needed at runtime +/CHANGELOG.md +/CODE_OF_CONDUCT.md +/CONTRIBUTING.md +/LICENSE.md +/NOTICE.md +/README.md +/RELEASE_NOTES.md +/SECURITY.md +/ROADMAP.md +/BENCHMARK-VIEWER-DESIGN.md +/AGENT-STATUS +/ENVIRONMENT_VARIABLES.md +/CLAUDE.md +/_config.yml +/CNAME +/create_admin.rb diff --git a/Dockerfile b/Dockerfile index 9f9d82cd8..82f400057 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,9 +59,9 @@ ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so" \ BUNDLE_PATH="/usr/local/bundle" # ============================================================================= -# BUILD STAGE - Compile gems and assets (for production) +# BUILD-BASE STAGE - Build tools + Node.js (shared by build and development) # ============================================================================= -FROM base AS build +FROM base AS build-base # Install packages needed to build gems and node modules RUN apt-get update -qq && \ @@ -84,6 +84,11 @@ RUN ARCH=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "arm64") && \ rm node.tar.xz && \ corepack enable +# ============================================================================= +# BUILD STAGE - Compile gems and assets (for production) +# ============================================================================= +FROM build-base AS build + # Build stage environment - production mode for asset compilation # Note: NODE_ENV is NOT set here because yarn skips devDependencies when NODE_ENV=production # and we need devDependencies (esbuild, sass-plugin, etc.) to build assets @@ -107,40 +112,25 @@ COPY . . RUN bundle exec bootsnap precompile app/ lib/ # Precompile Rails assets -RUN SECRET_KEY_BASE=dummyvalue ./bin/rails assets:precompile +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile -# Remove dev/test files and node_modules to reduce image size +# Remove dev/test files, node_modules, and source maps to reduce image size # Note: app/assets/ can be deleted - assets:precompile copies to public/assets/ -RUN rm -rf node_modules tmp/cache app/assets vendor/assets spec test .git +RUN rm -rf node_modules tmp/cache app/assets vendor/assets spec test .git && \ + find public/assets -name '*.map' -delete 2>/dev/null || true # ============================================================================= # DEVELOPMENT STAGE - Full development environment # ============================================================================= -FROM base AS development +FROM build-base AS development -# Install development packages +# Additional dev tools (build deps + Node.js already in build-base) RUN apt-get update -qq && \ apt-get install --no-install-recommends -y \ - build-essential \ - git \ - gnupg \ - libpq-dev \ - libyaml-dev \ - pkg-config \ - zlib1g-dev \ vim \ less && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives -# Install Node.js and yarn -ARG NODE_VERSION -ARG TARGETARCH -RUN ARCH=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "arm64") && \ - curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${ARCH}.tar.xz -o node.tar.xz && \ - tar -xJf node.tar.xz -C /usr/local --strip-components=1 && \ - rm node.tar.xz && \ - corepack enable - # Development environment ENV RAILS_ENV="development" \ BUNDLE_WITHOUT="" \ @@ -175,46 +165,25 @@ CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"] # ============================================================================= FROM base AS production -# Production environment with sensible defaults for Docker deployments -# These defaults allow `docker compose up` to work immediately without a .env file -# Override any of these via environment variables or a .env file -# -# Note: Database credentials (POSTGRES_PASSWORD) are NOT set here to avoid -# the SecretsUsedInArgOrEnv lint warning. They are set in docker-compose.yml -# via variable substitution with defaults: ${POSTGRES_PASSWORD:-postgres} +# Production environment — infrastructure only (12-factor: config via env vars at deploy time) +# App config defaults live in config/vulcan.default.yml, database.yml, and production.rb. +# Override at deploy time via docker-compose environment:, env_file:, or .env. ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_WITHOUT="development:test" \ RAILS_LOG_TO_STDOUT="true" \ - RAILS_SERVE_STATIC_FILES="true" \ - # Database defaults (credentials provided by docker-compose.yml or DATABASE_URL) - POSTGRES_USER="postgres" \ - POSTGRES_DB="vulcan_postgres_production" \ - DATABASE_HOST="db" \ - DATABASE_PORT="5432" \ - # Authentication defaults (local login enabled for quick demos) - VULCAN_ENABLE_LOCAL_LOGIN="true" \ - VULCAN_ENABLE_USER_REGISTRATION="true" \ - VULCAN_ENABLE_OIDC="false" \ - VULCAN_ENABLE_LDAP="false" \ - # Admin bootstrap: first user to register becomes admin (for demos/dev) - # For production, use VULCAN_ADMIN_EMAIL + VULCAN_ADMIN_PASSWORD instead - VULCAN_FIRST_USER_ADMIN="true" \ - # SSL: disabled by default for Docker quickstart (enable via reverse proxy) - RAILS_FORCE_SSL="false" \ - # Server port - PORT="3000" - -# Copy built artifacts from build stage -# Note: cleanup already done in build stage to minimize copy size -COPY --from=build /usr/local/bundle /usr/local/bundle -COPY --from=build /rails /rails + RAILS_SERVE_STATIC_FILES="true" -# Create non-root user and set ownership +# Create non-root user before COPY --chown (avoids extra chown layer) RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ - mkdir -p db log storage tmp && \ - chown -R rails:rails db log storage tmp public + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash + +# Copy built artifacts from build stage with correct ownership +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build --chown=rails:rails /rails /rails + +# Ensure writable directories exist +RUN mkdir -p db log storage tmp USER 1000:1000 diff --git a/docker-bake.hcl b/docker-bake.hcl index 37a8f6eaa..c1a97676f 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -26,22 +26,22 @@ variable "IMAGE_NAME" { } variable "VERSION" { - default = "2.3.1" + default = "latest" +} + +variable "BUNDLER_VERSION" { + default = "2.3.27" } // Use VULCAN_RUBY_VERSION to avoid conflict with RVM's RUBY_VERSION variable "VULCAN_RUBY_VERSION" { - default = "3.3.9" + default = "3.4.8" } variable "VULCAN_NODE_VERSION" { default = "22.16.0" } -variable "WEB_PORT" { - default = "3000" -} - // ============================================================================ // Groups - Build multiple targets at once // ============================================================================ @@ -71,9 +71,10 @@ target "production" { platforms = ["linux/amd64"] args = { - RUBY_VERSION = "${VULCAN_RUBY_VERSION}" - NODE_VERSION = "${VULCAN_NODE_VERSION}" - WEB_PORT = "${WEB_PORT}" + RUBY_VERSION = "${VULCAN_RUBY_VERSION}" + NODE_VERSION = "${VULCAN_NODE_VERSION}" + BUNDLER_VERSION = "${BUNDLER_VERSION}" + } labels = { @@ -124,9 +125,10 @@ target "dev" { platforms = ["linux/amd64"] args = { - RUBY_VERSION = "${VULCAN_RUBY_VERSION}" - NODE_VERSION = "${VULCAN_NODE_VERSION}" - WEB_PORT = "${WEB_PORT}" + RUBY_VERSION = "${VULCAN_RUBY_VERSION}" + NODE_VERSION = "${VULCAN_NODE_VERSION}" + BUNDLER_VERSION = "${BUNDLER_VERSION}" + } labels = { From 1f32286e86364125161a2412fe391522ccdbc642 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 28 Feb 2026 16:09:19 -0500 Subject: [PATCH 322/428] chore: Docker compose standardization - docker-compose.yml: simplified for development only - docker-compose.dev.yml: removed (merged into compose.yml) - docker-compose.prod.yml: new production compose - Required .env (fail-fast without secrets) - Healthcheck on /up (Rails standard) - Removed redundant ENV re-declarations - Removed POSTGRES_PASSWORD default - Add Caddyfile.example and nginx.conf.example - Add docker-compose.caddy-test.yml for HTTPS testing Authored by: Aaron Lippold --- Caddyfile.example | 53 +++++++++++++ docker-compose.caddy-test.yml | 27 +++++++ docker-compose.dev.yml | 28 ------- docker-compose.prod.yml | 137 ++++++++++++++++++++++++++++++++++ docker-compose.yml | 74 +++++------------- nginx.conf.example | 59 +++++++++++++++ 6 files changed, 294 insertions(+), 84 deletions(-) create mode 100644 Caddyfile.example create mode 100644 docker-compose.caddy-test.yml delete mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.prod.yml create mode 100644 nginx.conf.example diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 000000000..44b4bde60 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,53 @@ +# Vulcan Caddyfile — Caddy reverse proxy configuration +# +# Usage: +# cp Caddyfile.example Caddyfile +# Edit the domain below, then: +# docker compose -f docker-compose.prod.yml --profile caddy up -d +# +# Domain options: +# vulcan.localhost — Local dev, browsers auto-trust *.localhost certs +# vulcan.example.com — Production, Caddy auto-provisions Let's Encrypt +# :443 — Any hostname, uses tls internal (self-signed) + +# --- Local development (no cert setup needed) --- +vulcan.localhost { + encode gzip + + reverse_proxy web:3000 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} + +# --- Production with real domain (auto Let's Encrypt) --- +# Uncomment and replace with your domain. Remove the vulcan.localhost block above. +# +# vulcan.example.com { +# encode gzip +# +# reverse_proxy web:3000 { +# header_up X-Real-IP {remote_host} +# header_up X-Forwarded-For {remote_host} +# header_up X-Forwarded-Proto {scheme} +# } +# } + +# --- Custom hostname with internal TLS (e.g., vulcan.internal) --- +# Add hostname to /etc/hosts first: 127.0.0.1 vulcan.internal +# After first start, trust Caddy's CA: +# docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/caddy-root.crt +# macOS: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/caddy-root.crt +# Linux: sudo cp /tmp/caddy-root.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates +# +# vulcan.internal { +# tls internal +# encode gzip +# +# reverse_proxy web:3000 { +# header_up X-Real-IP {remote_host} +# header_up X-Forwarded-For {remote_host} +# header_up X-Forwarded-Proto {scheme} +# } +# } diff --git a/docker-compose.caddy-test.yml b/docker-compose.caddy-test.yml new file mode 100644 index 000000000..81ef15b24 --- /dev/null +++ b/docker-compose.caddy-test.yml @@ -0,0 +1,27 @@ +# Temporary test overlay — enables Caddy proxy +# Usage: docker compose -f docker-compose.prod.yml -f docker-compose.caddy-test.yml up -d + +services: + web: + ports: !reset [] + expose: + - "3000" + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "9080:80" + - "9443:443" + - "9443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + web: + condition: service_healthy + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 64a6368a6..000000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,28 +0,0 @@ -# Development-only Docker Compose -# Just runs PostgreSQL with sensible defaults for local development -# -# Usage: -# docker-compose -f docker-compose.dev.yml up -d -# bin/rails db:create db:migrate -# bin/dev - -services: - db: - image: postgres:16-alpine - restart: unless-stopped - ports: - - "${POSTGRES_PORT:-5432}:5432" - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB: ${POSTGRES_DB:-vulcan_vue_development} - volumes: - - vulcan_dev_dbdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - vulcan_dev_dbdata: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 000000000..a51d9097c --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,137 @@ +# Vulcan Production Docker Compose +# +# ============================================================================ +# Quick Start (HTTP — local testing only): +# ============================================================================ +# ./setup-docker-secrets.sh # Generate .env with secrets +# docker compose -f docker-compose.prod.yml up # Start Vulcan +# Open http://localhost:3000 +# Register first user — they become admin automatically +# +# ============================================================================ +# Production with SSL (choose Caddy OR nginx): +# ============================================================================ +# 1. ./setup-docker-secrets.sh +# 2. Edit .env: set RAILS_FORCE_SSL=true +# 3. Uncomment ONE of the proxy profiles below (caddy or nginx) +# 4. Copy and edit the matching config file: +# - Caddy: cp Caddyfile.example Caddyfile (edit domain) +# - nginx: cp nginx.conf.example nginx.conf (edit domain + certs) +# 5. docker compose -f docker-compose.prod.yml --profile caddy up -d +# OR: docker compose -f docker-compose.prod.yml --profile nginx up -d +# +# With a proxy, web only listens internally (expose: 3000, no host port). +# The proxy handles SSL termination and forwards X-Forwarded-Proto: https. +# +# For development: use docker-compose.yml (the default) instead. +# +# Database setup (db:prepare) runs automatically on container start via +# the docker-entrypoint script. Admin bootstrap hooks into db:prepare. + +services: + db: + image: postgres:18-alpine + restart: unless-stopped + volumes: + - vulcan_dbdata:/var/lib/postgresql/data + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} #ggignore + POSTGRES_DB: ${POSTGRES_DB:-vulcan_postgres_production} + expose: + - "5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 30s + timeout: 10s + retries: 5 + + web: + build: + context: . + dockerfile: Dockerfile + target: production + environment: + # Database connection (deployment-specific) + DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB:-vulcan_postgres_production} #ggignore + NODE_ENV: production + # .env file provides secrets (SECRET_KEY_BASE, CIPHER_PASSWORD, POSTGRES_PASSWORD, etc.) + # and optional config overrides (RAILS_FORCE_SSL, VULCAN_ENABLE_OIDC, etc.) + # Generate with: ./setup-docker-secrets.sh + env_file: + - path: .env + restart: unless-stopped + # When using a proxy, comment out "ports" and uncomment "expose" instead: + ports: + - "3000:3000" + # expose: + # - "3000" + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/up || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # ========================================================================== + # OPTION A: Caddy reverse proxy (auto-HTTPS, zero cert config) + # ========================================================================== + # Uncomment to enable. Set RAILS_FORCE_SSL=true in .env. + # Copy Caddyfile.example to Caddyfile and edit your domain. + # + # For local testing: use "vulcan.localhost" (browsers auto-trust *.localhost) + # For production: use your real domain (Caddy auto-provisions Let's Encrypt) + # + # caddy: + # image: caddy:2-alpine + # profiles: ["caddy"] + # restart: unless-stopped + # ports: + # - "80:80" + # - "443:443" + # - "443:443/udp" + # volumes: + # - ./Caddyfile:/etc/caddy/Caddyfile:ro + # - caddy_data:/data + # - caddy_config:/config + # # Corporate/custom CA certs (same certs/ dir used by the web container) + # # - ./certs:/usr/local/share/ca-certificates/custom:ro + # depends_on: + # web: + # condition: service_healthy + + # ========================================================================== + # OPTION B: nginx reverse proxy (manual cert management) + # ========================================================================== + # Uncomment to enable. Set RAILS_FORCE_SSL=true in .env. + # Copy nginx.conf.example to nginx.conf and edit your domain. + # Place your SSL certs in config/nginx/certs/ (or use mkcert for local dev). + # + # For local testing with mkcert: + # brew install mkcert && mkcert -install + # mkdir -p config/nginx/certs + # mkcert -cert-file config/nginx/certs/cert.pem -key-file config/nginx/certs/key.pem vulcan.localhost + # + # nginx: + # image: nginx:alpine + # profiles: ["nginx"] + # restart: unless-stopped + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + # - ./config/nginx/certs:/etc/nginx/certs:ro + # # Corporate/custom CA certs (same certs/ dir used by the web container) + # # - ./certs:/usr/local/share/ca-certificates/custom:ro + # depends_on: + # web: + # condition: service_healthy + +volumes: + vulcan_dbdata: + # caddy_data: + # caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml index fc9856f46..6d8214c92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,69 +1,31 @@ -# Vulcan Docker Compose Configuration +# Development Docker Compose (default) +# Runs PostgreSQL with sensible defaults for local development. # -# Quick Start: -# ./setup-docker-secrets.sh # Generate .env with secrets (one time) -# docker compose up # Start Vulcan -# Open http://localhost:3000 -# Register first user - they become admin automatically +# Usage: +# docker compose up -d +# bin/rails db:prepare +# foreman start -f Procfile.dev # -# For production deployments: -# 1. Run ./setup-docker-secrets.sh -# 2. Edit .env to configure authentication (OIDC, LDAP, or local) -# 3. Set VULCAN_ADMIN_EMAIL and VULCAN_ADMIN_PASSWORD for admin bootstrap -# 4. docker compose up -d -# -# Database setup (db:prepare) runs automatically on container start via -# the docker-entrypoint script. Admin bootstrap hooks into db:prepare. +# Production: use docker-compose.prod.yml instead. +name: vulcan-v2x services: db: image: postgres:18-alpine restart: unless-stopped - volumes: - - vulcan_dbdata:/var/lib/postgresql/data + ports: + - "${POSTGRES_PORT:-5432}:5432" environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} #ggignore - POSTGRES_DB: ${POSTGRES_DB:-vulcan_postgres_production} - expose: - - "5432" + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-vulcan_vue_development} + volumes: + - vulcan_dev_dbdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 30s - timeout: 10s + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 10s + timeout: 5s retries: 5 - web: - build: - context: . - dockerfile: Dockerfile - target: production - environment: - # Database connection - uses defaults from Dockerfile if not set - DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db/${POSTGRES_DB:-vulcan_postgres_production} #ggignore - RAILS_SERVE_STATIC_FILES: "true" - RAILS_ENV: production - NODE_ENV: production - # jemalloc is enabled in the Dockerfile - LD_PRELOAD: /usr/local/lib/libjemalloc.so - MALLOC_ARENA_MAX: "2" - # .env file provides secrets (SECRET_KEY_BASE, CIPHER_PASSWORD, etc.) - # Generate with: ./setup-docker-secrets.sh - env_file: - - path: .env - required: false - restart: unless-stopped - ports: - - "3000:3000" - depends_on: - db: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3000/users/sign_in || exit 1"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - volumes: - vulcan_dbdata: + vulcan_dev_dbdata: diff --git a/nginx.conf.example b/nginx.conf.example new file mode 100644 index 000000000..f9393abf6 --- /dev/null +++ b/nginx.conf.example @@ -0,0 +1,59 @@ +# Vulcan nginx configuration — SSL reverse proxy +# +# Usage: +# cp nginx.conf.example nginx.conf +# Edit server_name and ssl_certificate paths below, then: +# docker compose -f docker-compose.prod.yml --profile nginx up -d +# +# For local dev with mkcert: +# brew install mkcert && mkcert -install +# mkdir -p config/nginx/certs +# mkcert -cert-file config/nginx/certs/cert.pem -key-file config/nginx/certs/key.pem vulcan.localhost + +upstream vulcan { + server web:3000; +} + +# Redirect HTTP to HTTPS +server { + listen 80; + server_name vulcan.localhost; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name vulcan.localhost; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; + + location / { + proxy_pass http://vulcan; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 90; + + # WebSocket support (Action Cable) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Cache static assets + location /assets/ { + proxy_pass http://vulcan; + proxy_cache_valid 200 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + } +} From e5675f89716f68d76a92362a0f030110b8bb55a1 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 28 Feb 2026 16:09:35 -0500 Subject: [PATCH 323/428] fix: database.yml DRY defaults, session_store SSL, CI env - database.yml: DRY defaults block with env-configurable host, port, gssencmode; remove duplicated per-env settings - session_store.rb: secure cookie tied to RAILS_FORCE_SSL env var instead of Rails.env (fixes HTTP login in prod) - ci.yml: add DATABASE_HOST, POSTGRES_USER, POSTGRES_PASSWORD env vars (needed after removing hardcoded database.yml) - .env.example: document port/host/gssencmode options Authored by: Aaron Lippold --- .env.example | 20 +++++--- .github/workflows/ci.yml | 5 ++ config/database.yml | 72 ++++------------------------ config/initializers/session_store.rb | 2 +- 4 files changed, 29 insertions(+), 70 deletions(-) diff --git a/.env.example b/.env.example index d30833863..88d849f4a 100644 --- a/.env.example +++ b/.env.example @@ -6,15 +6,23 @@ # ============================================================================= # DATABASE # ============================================================================= +# Defaults (port 5432, host 127.0.0.1) work for single-project development. +# Running multiple projects simultaneously? Assign unique ports per project. +# See docs/development/port-registry.md for recommended port assignments. +# +# DATABASE_PORT=5432 +# DATABASE_HOST=127.0.0.1 +# POSTGRES_PORT=5432 +# +# macOS with Kerberos/GSSAPI connection errors (corporate networks): +# DATABASE_GSSENCMODE=disable +# # Worktree isolation: suffix appended to database names in database.yml -# Set this when using multiple git worktrees to avoid migration conflicts # Each worktree gets its own database (e.g., vulcan_vue_development_v2) -# Not needed in production — each deployment has its own database. # DB_SUFFIX=_v2 - -# Development database URL (for local Rails development) -# DATABASE_URL commented out - let database.yml handle dev/test separation -# DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/vulcan_vue_development +# +# App server port (Puma): +# PORT=3000 # Docker database password (used by docker-compose) POSTGRES_PASSWORD=postgres diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc4941f56..82d18ca55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,11 @@ jobs: - 10389:10389 env: + # Application config (read by database.yml) + DATABASE_HOST: localhost + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + # libpq vars (read by pg_isready, psql CLI tools) PGHOST: localhost PGUSER: postgres PGPASSWORD: postgres diff --git a/config/database.yml b/config/database.yml index b3950d5d0..d1f060175 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,82 +1,28 @@ -# PostgreSQL. Versions 9.3 and up are supported. -# -# Install the pg driver: -# gem install pg -# On macOS with Homebrew: -# gem install pg -- --with-pg-config=/usr/local/bin/pg_config -# On macOS with MacPorts: -# gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config -# On Windows: -# gem install pg -# Choose the win32 build. -# Install PostgreSQL and put its /bin directory on your path. -# -# Configure Using Gemfile -# gem 'pg' +# PostgreSQL configuration +# All connection settings are configurable via environment variables. +# Defaults work for single-project development on standard ports. +# For multi-project setups, set DATABASE_PORT/DATABASE_HOST in your .env file. # default: &default adapter: postgresql encoding: unicode - # For details on connection pooling, see Rails configuration guide - # https://guides.rubyonrails.org/configuring.html#database-pooling + host: <%= ENV.fetch('DATABASE_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('DATABASE_PORT', 5432) %> + username: <%= ENV.fetch('POSTGRES_USER', 'postgres') %> + password: <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %> + gssencmode: <%= ENV.fetch('DATABASE_GSSENCMODE', 'prefer') %> pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> development: <<: *default database: vulcan_vue_development<%= ENV['DB_SUFFIX'] %> - host: 127.0.0.1 - port: 5432 - username: postgres - password: postgres - # Schema search path. The server defaults to $user,public - #schema_search_path: myapp,sharedapp,public - - # Minimum log levels, in increasing order: - # debug5, debug4, debug3, debug2, debug1, - # log, notice, warning, error, fatal, and panic - # Defaults to warning. - #min_messages: notice - -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. test: <<: *default database: vulcan_vue_test<%= ENV['DB_SUFFIX'] %><%= ENV['TEST_ENV_NUMBER'] %> - host: 127.0.0.1 - port: 5432 - username: postgres - password: postgres -# As with config/credentials.yml, you never want to store sensitive information, -# like your database password, in your source code. If your source code is -# ever seen by anyone, they now have access to your database. -# -# Instead, provide the password as a unix environment variable when you boot -# the app. Read https://guides.rubyonrails.org/configuring.html#configuring-a-database -# for a full rundown on how to provide these environment variables in a -# production deployment. -# -# On Heroku and other platform providers, you may have a full connection URL -# available as an environment variable. For example: -# -# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" -# -# You can use this database configuration with: -# -# production: -# url: <%= ENV['DATABASE_URL'] %> -# production: <<: *default # DATABASE_URL takes precedence (12-factor standard) - # Recommended for Heroku, Kubernetes, and docker-compose deployments url: <%= ENV['DATABASE_URL'] %> - # Fallback configuration if DATABASE_URL is not set - host: <%= ENV.fetch('DATABASE_HOST', 'localhost') %> - port: <%= ENV.fetch('DATABASE_PORT', 5432) %> database: <%= ENV.fetch('POSTGRES_DB', 'vulcan_postgres_production') %> - username: <%= ENV.fetch('POSTGRES_USER', 'postgres') %> - # Support both new POSTGRES_PASSWORD and legacy VULCAN_VUE_DATABASE_PASSWORD - password: <%= ENV['POSTGRES_PASSWORD'] || ENV['VULCAN_VUE_DATABASE_PASSWORD'] %> diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 677f1341e..01f1e2951 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -4,6 +4,6 @@ # Cookie-based sessions can't be invalidated server-side; ActiveRecord store can. Rails.application.config.session_store :active_record_store, key: '_vulcan_session', - secure: Rails.env.production?, + secure: ENV.fetch('RAILS_FORCE_SSL', 'true').downcase != 'false', httponly: true, same_site: :lax From 9409fda24c296478fb6ab7ded630367ce0e568b8 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 28 Feb 2026 16:09:58 -0500 Subject: [PATCH 324/428] feat: SRG auto-detect from spreadsheet import - POST /components/detect_srg endpoint peeks at SRGID column to identify which SRG a spreadsheet belongs to - SpreadsheetParser.peek_srg_ids class method extracts unique SRG IDs without full validation - NewComponentModal auto-detects SRG when file selected in spreadsheet_import mode (silent fallback on failure) - Shows loading spinner and success indicator in UI - 7 new frontend tests covering detect flow Authored by: Aaron Lippold --- app/controllers/components_controller.rb | 32 +++- .../components/NewComponentModal.vue | 44 ++++++ app/services/spreadsheet_parser.rb | 53 +++++-- config/routes.rb | 2 + .../components/NewComponentModal.spec.js | 144 +++++++++++++++++- 5 files changed, 259 insertions(+), 16 deletions(-) diff --git a/app/controllers/components_controller.rb b/app/controllers/components_controller.rb index 9d32b1104..d82be6075 100644 --- a/app/controllers/components_controller.rb +++ b/app/controllers/components_controller.rb @@ -21,7 +21,7 @@ class ComponentsController < ApplicationController before_action :check_permission_to_update_slackchannel, only: %i[update] before_action :check_admin_for_advanced_fields, only: %i[update] before_action :authorize_component_access, only: %i[show export find] - before_action :authorize_logged_in, only: %i[search index based_on_same_srg bulk_export] + before_action :authorize_logged_in, only: %i[search index based_on_same_srg bulk_export detect_srg] before_action :authorize_compare_access, only: %i[compare] before_action :authorize_viewer_project, only: %i[history] before_action :validate_component_upload, only: :create @@ -301,6 +301,36 @@ def history render json: history end + def detect_srg + file = params[:file] + unless file + render json: { error: 'No file provided' }, status: :unprocessable_entity + return + end + + srg_ids = SpreadsheetParser.peek_srg_ids(file) + if srg_ids.empty? + render json: { error: 'No SRG IDs found in spreadsheet' }, status: :unprocessable_entity + return + end + + # Find which SRGs these rule IDs belong to + rules = SrgRule.where(version: srg_ids).includes(:security_requirements_guide) + srgs = rules.map(&:security_requirements_guide).uniq + + if srgs.empty? + render json: { error: 'Could not identify a matching SRG for the IDs in this spreadsheet' }, + status: :unprocessable_entity + elsif srgs.size > 1 + names = srgs.map { |s| "#{s.title} (#{s.version})" }.join(', ') + render json: { error: "SRG IDs map to multiple SRGs: #{names}. Please select manually." }, + status: :unprocessable_entity + else + srg = srgs.first + render json: { id: srg.id, srg_id: srg.srg_id, title: srg.title, version: srg.version } + end + end + def preview_spreadsheet_update file = params[:file] unless file diff --git a/app/javascript/components/components/NewComponentModal.vue b/app/javascript/components/components/NewComponentModal.vue index 35139251c..32901729b 100644 --- a/app/javascript/components/components/NewComponentModal.vue +++ b/app/javascript/components/components/NewComponentModal.vue @@ -77,8 +77,15 @@ :min-length="0" :max-suggestions="0" :number="0" + :disabled="detecting" @select="setSelectedSrg($refs.srgSearch.selected)" /> + + Detecting SRG from spreadsheet... + + + SRG auto-detected from spreadsheet + @@ -251,6 +258,8 @@ export default { srgs: [], displayedSrgs: [], file: null, + detecting: false, + srgAutoDetected: false, componentKey: 0, potentialPocs: this.project ? this.project.users : [], admin_name: "", @@ -284,7 +293,39 @@ export default { } }, }, + watch: { + file: function (newFile) { + if (newFile && this.spreadsheet_import) { + this.detectSrg(newFile); + } else { + this.srgAutoDetected = false; + } + }, + }, methods: { + detectSrg: function (file) { + this.detecting = true; + this.srgAutoDetected = false; + let formData = new FormData(); + formData.append("file", file); + axios + .post("/components/detect_srg", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }) + .then((response) => { + const detected = response.data; + this.security_requirements_guide_id = detected.id; + this.security_requirements_guide_displayed = `${detected.title} (${detected.version})`; + this.srgAutoDetected = true; + }) + .catch(() => { + // Detection failed — user picks manually, no error needed + this.srgAutoDetected = false; + }) + .finally(() => { + this.detecting = false; + }); + }, showModal: function () { this.selected_project_id = this.project_id; this.selected_component_id = null; @@ -303,6 +344,9 @@ export default { ? this.addDisplayNameToComponents(this.project.components) : []; this.displayedSrgs = []; + this.file = null; + this.detecting = false; + this.srgAutoDetected = false; this.$refs["AddComponentModal"].show(); }, setComponentPoc: function (user) { diff --git a/app/services/spreadsheet_parser.rb b/app/services/spreadsheet_parser.rb index 473778f9a..d83efbcc5 100644 --- a/app/services/spreadsheet_parser.rb +++ b/app/services/spreadsheet_parser.rb @@ -7,6 +7,45 @@ class SpreadsheetParser attr_reader :errors + # Lightweight class method: peek at the SRGID column without full validation. + # Returns an array of unique, non-blank SRG ID strings found in the file. + # Returns [] on any error (missing column, parse failure, empty file). + # + # @param spreadsheet [String, ActionDispatch::Http::UploadedFile] path or uploaded file + # @return [Array] + # Normalize aliased export headers back to import header names. + # Shared by peek_srg_ids and instance normalize_headers. + # + # @param rows [Array] parsed spreadsheet rows with header keys + # @return [Array] rows with aliased headers renamed + def self.normalize_header_aliases(rows) + return rows if rows.empty? + + file_headers = rows.first.keys + rename_map = {} + ImportConstants::HEADER_ALIASES.each do |export_header, import_header| + rename_map[export_header] = import_header if file_headers.include?(export_header) + end + + return rows if rename_map.empty? + + rows.map { |row| row.transform_keys { |key| rename_map[key] || key } } + end + + def self.peek_srg_ids(spreadsheet) + rows = Roo::Spreadsheet.open(spreadsheet).sheet(0).parse(headers: true).drop(1) + return [] if rows.empty? + + rows = normalize_header_aliases(rows) + + srg_id_col = ImportConstants::IMPORT_MAPPING[:srg_id] # "SRGID" + return [] unless rows.first.key?(srg_id_col) + + rows.pluck(srg_id_col).compact_blank.uniq + rescue StandardError + [] + end + # @param spreadsheet [String, ActionDispatch::Http::UploadedFile] path or uploaded file # @param srg_id [Integer] SecurityRequirementsGuide ID to validate against def initialize(spreadsheet, srg_id) @@ -53,19 +92,7 @@ def open_spreadsheet end def normalize_headers(parsed) - return parsed if parsed.empty? - - file_headers = parsed.first.keys - rename_map = {} - HEADER_ALIASES.each do |export_header, import_header| - rename_map[export_header] = import_header if file_headers.include?(export_header) - end - - return parsed if rename_map.empty? - - parsed.map do |row| - row.transform_keys { |key| rename_map[key] || key } - end + self.class.normalize_header_aliases(parsed) end def error_result(message) diff --git a/config/routes.rb b/config/routes.rb index 42480eb04..a914dc3c9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -71,6 +71,8 @@ get '/components/:id/search/based_on_same_srg', to: 'components#based_on_same_srg' # Compare components get '/components/:id/compare/:diff_id', to: 'components#compare' + # Detect SRG from spreadsheet (auto-populate dropdown on import) + post '/components/detect_srg', to: 'components#detect_srg' # Spreadsheet round-trip update post '/components/:id/preview_spreadsheet_update', to: 'components#preview_spreadsheet_update' patch '/components/:id/apply_spreadsheet_update', to: 'components#apply_spreadsheet_update' diff --git a/spec/javascript/components/components/NewComponentModal.spec.js b/spec/javascript/components/components/NewComponentModal.spec.js index d16f6f450..53935abc0 100644 --- a/spec/javascript/components/components/NewComponentModal.spec.js +++ b/spec/javascript/components/components/NewComponentModal.spec.js @@ -1,9 +1,10 @@ -import { describe, it, expect, afterEach, vi } from "vitest"; +import { describe, it, expect, afterEach, vi, beforeEach } from "vitest"; import { shallowMount, mount } from "@vue/test-utils"; import { localVue } from "@test/testHelper"; import NewComponentModal from "@/components/components/NewComponentModal.vue"; +import axios from "axios"; -// Mock axios (used by fetchData and createComponent) +// Mock axios (used by fetchData, createComponent, detectSrg) vi.mock("axios", () => ({ default: { get: vi.fn(() => Promise.resolve({ data: [] })), @@ -187,4 +188,143 @@ describe("NewComponentModal", () => { expect(accept).toContain("application/vnd.ms-excel"); }); }); + + // ========================================== + // SRG AUTO-DETECT FROM SPREADSHEET + // + // REQUIREMENTS: + // 1. When user selects a file in spreadsheet_import mode, + // the system should attempt to detect which SRG the + // spreadsheet belongs to by calling POST /components/detect_srg + // 2. On success: auto-populate the SRG dropdown (no manual selection needed) + // 3. On failure: silently fall back to manual SRG selection (no error toast) + // 4. Show a loading indicator while detecting + // 5. Show a success indicator when SRG was auto-detected + // 6. The SRG dropdown should be disabled while detecting + // 7. User can still override the auto-detected SRG manually + // ========================================== + describe("SRG auto-detect from spreadsheet", () => { + const ModalStub = { + template: "
    ", + }; + + const createMountedWrapper = (props = {}) => { + return mount(NewComponentModal, { + localVue, + propsData: { + ...defaultProps, + spreadsheet_import: true, + ...props, + }, + stubs: { + "b-modal": ModalStub, + VueSimpleSuggest: true, + }, + }); + }; + + const mockFile = new File(["test"], "test.csv", { type: "text/csv" }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls detect_srg endpoint when file is selected in spreadsheet_import mode", async () => { + const detectResponse = { + data: { id: 42, srg_id: "SRG-APP-000001", title: "App SRG", version: "V3R3" }, + }; + axios.post.mockResolvedValueOnce(detectResponse); + + wrapper = createMountedWrapper(); + await wrapper.setData({ file: mockFile }); + await vi.dynamicImportSettled(); + + expect(axios.post).toHaveBeenCalledWith( + "/components/detect_srg", + expect.any(FormData), + expect.objectContaining({ headers: { "Content-Type": "multipart/form-data" } }), + ); + }); + + it("auto-populates SRG when detection succeeds", async () => { + const detectResponse = { + data: { id: 42, srg_id: "SRG-APP-000001", title: "App SRG", version: "V3R3" }, + }; + axios.post.mockResolvedValueOnce(detectResponse); + + wrapper = createMountedWrapper(); + await wrapper.setData({ file: mockFile }); + // Wait for promise chain + await new Promise((r) => setTimeout(r, 10)); + + expect(wrapper.vm.security_requirements_guide_id).toBe(42); + expect(wrapper.vm.security_requirements_guide_displayed).toBe("App SRG (V3R3)"); + expect(wrapper.vm.srgAutoDetected).toBe(true); + }); + + it("falls back silently when detection fails", async () => { + axios.post.mockRejectedValueOnce(new Error("422")); + + wrapper = createMountedWrapper(); + await wrapper.setData({ file: mockFile }); + await new Promise((r) => setTimeout(r, 10)); + + // SRG not set — user must pick manually + expect(wrapper.vm.security_requirements_guide_id).toBeFalsy(); + expect(wrapper.vm.srgAutoDetected).toBe(false); + }); + + it("sets detecting=true while request is in flight", async () => { + let resolveDetect; + axios.post.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetect = resolve; + }), + ); + + wrapper = createMountedWrapper(); + await wrapper.setData({ file: mockFile }); + + // While pending + expect(wrapper.vm.detecting).toBe(true); + + // Resolve + resolveDetect({ data: { id: 1, title: "SRG", version: "V1R1" } }); + await new Promise((r) => setTimeout(r, 10)); + + expect(wrapper.vm.detecting).toBe(false); + }); + + it("does NOT call detect_srg when not in spreadsheet_import mode", async () => { + wrapper = mount(NewComponentModal, { + localVue, + propsData: { ...defaultProps, spreadsheet_import: false }, + stubs: { "b-modal": ModalStub, VueSimpleSuggest: true }, + }); + await wrapper.setData({ file: mockFile }); + await new Promise((r) => setTimeout(r, 10)); + + // Only the fetchData calls — no detect_srg POST + expect(axios.post).not.toHaveBeenCalledWith( + "/components/detect_srg", + expect.anything(), + expect.anything(), + ); + }); + + it("resets srgAutoDetected when file is cleared", async () => { + axios.post.mockResolvedValueOnce({ + data: { id: 42, title: "SRG", version: "V1R1" }, + }); + + wrapper = createMountedWrapper(); + await wrapper.setData({ file: mockFile }); + await new Promise((r) => setTimeout(r, 10)); + expect(wrapper.vm.srgAutoDetected).toBe(true); + + // Clear the file + await wrapper.setData({ file: null }); + expect(wrapper.vm.srgAutoDetected).toBe(false); + }); + }); }); From e8cd8b944092480869ce070679470ade32a8cd8f Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 28 Feb 2026 16:10:15 -0500 Subject: [PATCH 325/428] docs: update docs for Docker standardization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update all docker-compose.yml refs to docker-compose.prod.yml - ENVIRONMENT_VARIABLES.md: add DATABASE_PORT, DATABASE_HOST, DATABASE_GSSENCMODE, PORT; clarify POSTGRES_PORT usage - bin/setup: auto-copy .env.example, start db, wait for pg - Procfile.dev: use PORT env var - .slugignore: swap dev→prod compose exclusion Authored by: Aaron Lippold --- .slugignore | 2 +- ENVIRONMENT_VARIABLES.md | 18 ++++++++----- Procfile.dev | 2 +- README.md | 8 +++--- bin/setup | 25 +++++++++++++++---- docs/development/release-process.md | 4 +-- docs/getting-started/environment-variables.md | 18 ++++++++----- docs/getting-started/installation.md | 2 +- docs/getting-started/quick-start.md | 4 +-- 9 files changed, 55 insertions(+), 28 deletions(-) diff --git a/.slugignore b/.slugignore index 37afad389..d9bc73411 100644 --- a/.slugignore +++ b/.slugignore @@ -37,7 +37,7 @@ bin/vulcan # Dev-only files Procfile.dev vitest.config.js -docker-compose.dev.yml +docker-compose.prod.yml setup-docker-secrets.sh # Documentation files not needed at runtime diff --git a/ENVIRONMENT_VARIABLES.md b/ENVIRONMENT_VARIABLES.md index 52ced7ba6..6a5581620 100644 --- a/ENVIRONMENT_VARIABLES.md +++ b/ENVIRONMENT_VARIABLES.md @@ -14,11 +14,15 @@ This document lists all environment variables that can be used to configure Vulc | Variable | Description | Default | Example | |----------|-------------|---------|---------| | `DATABASE_URL` | PostgreSQL connection string (12-factor, takes precedence) | - | `postgres://user:pass@localhost:5432/vulcan_production` | +| `DATABASE_PORT` | PostgreSQL client connection port (used by database.yml) | `5432` | `5435` | +| `DATABASE_HOST` | PostgreSQL host (used by database.yml) | `127.0.0.1` | `localhost` | +| `DATABASE_GSSENCMODE` | GSSAPI encryption mode (set to `disable` on macOS with Kerberos) | `prefer` | `disable` | | `DB_SUFFIX` | Database name suffix for worktree isolation (development only) | - | `_v2`, `_v3` | -| `POSTGRES_USER` | PostgreSQL username | `postgres` | `vulcan_user` | -| `POSTGRES_PASSWORD` | PostgreSQL password | `postgres` | `secure_password` | -| `POSTGRES_DB` | PostgreSQL database name | `vulcan_postgres_production` | `vulcan_prod` | -| `DATABASE_PORT` | PostgreSQL port | `5432` | `5432` | +| `POSTGRES_PORT` | Docker host-side port mapping (should match DATABASE_PORT) | `5432` | `5435` | +| `POSTGRES_USER` | PostgreSQL username (Docker init + database.yml) | `postgres` | `vulcan_user` | +| `POSTGRES_PASSWORD` | PostgreSQL password (Docker init + database.yml) | `postgres` | `secure_password` | +| `POSTGRES_DB` | PostgreSQL database name (Docker init + production database.yml) | `vulcan_postgres_production` | `vulcan_prod` | +| `PORT` | Application server (Puma) listen port | `3000` | `3001` | **Note:** `DATABASE_URL` takes precedence when set (recommended for Heroku, Kubernetes). Individual variables (`POSTGRES_USER`, `POSTGRES_PASSWORD`, etc.) are used as fallback. @@ -34,6 +38,8 @@ DB_SUFFIX=_v3 # → vulcan_vue_development_v3, vulcan_vue_test_v3 **Deprecated:** `VULCAN_VUE_DATABASE_PASSWORD` is deprecated. Use `POSTGRES_PASSWORD` instead. +**Multi-Project Development**: See [docs/development/port-registry.md](docs/development/port-registry.md) for recommended port assignments when running multiple projects simultaneously. + ## General Application Settings | Variable | Description | Default | Example | @@ -321,12 +327,12 @@ In production, set these as actual environment variables through your deployment When using Docker, you can set environment variables in: - `.env` file (created by `setup-docker-secrets.sh`) -- `docker-compose.yml` using the `environment:` section +- `docker-compose.prod.yml` using the `environment:` section - Container runtime with `-e` flags **For Container Deployments** (Docker, ECS, Kubernetes): ```yaml -# docker-compose.yml +# docker-compose.prod.yml environment: RAILS_LOG_TO_STDOUT: "true" STRUCTURED_LOGGING: "true" # Enable JSON logging for CloudWatch/monitoring diff --git a/Procfile.dev b/Procfile.dev index b52312e8f..65dfc9878 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,2 @@ -web: bundle exec rails s -p 3000 +web: bundle exec rails s -p ${PORT:-3000} js: yarn build:watch diff --git a/README.md b/README.md index bd9ac259b..be0f478f4 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,11 @@ Vulcan models the Security Technical Implementation Guide (STIG) creation proces docker pull mitre/vulcan:v2.3.1 # Or use docker compose for a complete setup -wget https://raw.githubusercontent.com/mitre/vulcan/master/docker-compose.yml +wget https://raw.githubusercontent.com/mitre/vulcan/master/docker-compose.prod.yml wget https://raw.githubusercontent.com/mitre/vulcan/master/setup-docker-secrets.sh chmod +x setup-docker-secrets.sh ./setup-docker-secrets.sh -docker compose up +docker compose -f docker-compose.prod.yml up ``` The first user to register becomes admin automatically. @@ -183,12 +183,12 @@ bundle exec bundler-audit 4. **Start the application**: ```bash - docker-compose up -d + docker compose -f docker-compose.prod.yml up -d ``` 5. **Initialize database** (first time only): ```bash - docker-compose run --rm web bundle exec rake db:create db:schema:load db:migrate + docker compose -f docker-compose.prod.yml run --rm web bundle exec rake db:create db:schema:load db:migrate ``` ### Docker Image Features diff --git a/bin/setup b/bin/setup index a2f0f04f8..1cbffec32 100755 --- a/bin/setup +++ b/bin/setup @@ -12,7 +12,13 @@ FileUtils.chdir APP_ROOT do # This script is idempotent, so that you can run it at any time and get an expectable outcome. # Add necessary setup steps to this file. - puts "== Installing dependencies ==" + unless File.exist?(".env") + puts "== Copying .env.example to .env ==" + FileUtils.cp ".env.example", ".env" + puts " Created .env from .env.example — review and adjust settings as needed." + end + + puts "\n== Installing dependencies ==" system("bundle check") || system!("bundle install") puts "\n== Installing JavaScript dependencies ==" @@ -21,10 +27,19 @@ FileUtils.chdir APP_ROOT do puts "\n== Building JavaScript assets ==" system! "yarn build" - # puts "\n== Copying sample files ==" - # unless File.exist?("config/database.yml") - # FileUtils.cp "config/database.yml.sample", "config/database.yml" - # end + puts "\n== Starting development database ==" + system "docker compose up db -d" + + # Wait for PostgreSQL to be ready + pgport = ENV.fetch("DATABASE_PORT", "5432") + pghost = ENV.fetch("DATABASE_HOST", "127.0.0.1") + print " Waiting for PostgreSQL on #{pghost}:#{pgport}" + 30.times do + break if system("pg_isready -h #{pghost} -p #{pgport} -q 2>/dev/null") + print "." + sleep 1 + end + puts puts "\n== Preparing database ==" system! "bin/rails db:prepare" diff --git a/docs/development/release-process.md b/docs/development/release-process.md index 4fa61a923..d68f97251 100644 --- a/docs/development/release-process.md +++ b/docs/development/release-process.md @@ -106,8 +106,8 @@ docker pull mitre/vulcan:latest # Test locally ./setup-docker-secrets.sh # if not already done -# In docker-compose.yml, use: image: mitre/vulcan:v2.3.2 -docker compose up +# In docker-compose.prod.yml, use: image: mitre/vulcan:v2.3.2 +docker compose -f docker-compose.prod.yml up ``` ## Docker Image Details diff --git a/docs/getting-started/environment-variables.md b/docs/getting-started/environment-variables.md index d59c5eda3..3984bc6b7 100644 --- a/docs/getting-started/environment-variables.md +++ b/docs/getting-started/environment-variables.md @@ -14,14 +14,20 @@ This document lists all environment variables that can be used to configure Vulc | Variable | Description | Default | Example | |----------|-------------|---------|---------| | `DATABASE_URL` | PostgreSQL connection string (12-factor, takes precedence) | - | `postgres://user:pass@localhost:5432/vulcan_production` | +| `DATABASE_PORT` | PostgreSQL client connection port (used by database.yml) | `5432` | `5435` | +| `DATABASE_HOST` | PostgreSQL host (used by database.yml) | `127.0.0.1` | `localhost` | +| `DATABASE_GSSENCMODE` | GSSAPI encryption mode (set to `disable` on macOS with Kerberos) | `prefer` | `disable` | | `DB_SUFFIX` | Database name suffix for worktree isolation (development only) | - | `_v2`, `_v3` | -| `POSTGRES_USER` | PostgreSQL username | `postgres` | `vulcan_user` | -| `POSTGRES_PASSWORD` | PostgreSQL password | `postgres` | `secure_password` | -| `POSTGRES_DB` | PostgreSQL database name | `vulcan_postgres_production` | `vulcan_prod` | -| `DATABASE_PORT` | PostgreSQL port | `5432` | `5432` | +| `POSTGRES_PORT` | Docker host-side port mapping (should match DATABASE_PORT) | `5432` | `5435` | +| `POSTGRES_USER` | PostgreSQL username (Docker init + database.yml) | `postgres` | `vulcan_user` | +| `POSTGRES_PASSWORD` | PostgreSQL password (Docker init + database.yml) | `postgres` | `secure_password` | +| `POSTGRES_DB` | PostgreSQL database name (Docker init + production database.yml) | `vulcan_postgres_production` | `vulcan_prod` | +| `PORT` | Application server (Puma) listen port | `3000` | `3001` | **Note:** `DATABASE_URL` takes precedence when set (recommended for Heroku, Kubernetes). Individual variables (`POSTGRES_USER`, `POSTGRES_PASSWORD`, etc.) are used as fallback. +**Multi-Project Development**: See [port-registry](/development/port-registry) for recommended port assignments when running multiple projects simultaneously. + **Worktree Isolation**: When developing with multiple git worktrees (e.g., v2.x and v3.x), set `DB_SUFFIX` in each worktree's `.env` to give each branch its own database. This prevents migration conflicts when branches have diverging schemas. Not needed in production — each deployment has its own database. ```bash @@ -321,12 +327,12 @@ In production, set these as actual environment variables through your deployment When using Docker, you can set environment variables in: - `.env` file (created by `setup-docker-secrets.sh`) -- `docker-compose.yml` using the `environment:` section +- `docker-compose.prod.yml` using the `environment:` section - Container runtime with `-e` flags **For Container Deployments** (Docker, ECS, Kubernetes): ```yaml -# docker-compose.yml +# docker-compose.prod.yml environment: RAILS_LOG_TO_STDOUT: "true" STRUCTURED_LOGGING: "true" # Enable JSON logging for CloudWatch/monitoring diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index f02b36dc7..3400c173a 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -24,7 +24,7 @@ Given that Vulcan requires at least a database service, we use Docker Compose. 1. Install Docker 2. Download vulcan by running `git clone https://github.com/mitre/vulcan.git`. -3. Navigate to the base folder where `docker-compose.yml` is located +3. Navigate to the base folder where `docker-compose.prod.yml` is located 4. Run the following commands in a terminal window from the vulcan source directory: 1. `./setup-docker-secrets.sh` 2. `docker compose up -d` diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index aeb4f873b..1c73fc615 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -24,11 +24,11 @@ Before installing, you can try Vulcan directly: docker pull mitre/vulcan:latest # Or use docker compose for a complete setup -wget https://raw.githubusercontent.com/mitre/vulcan/master/docker-compose.yml +wget https://raw.githubusercontent.com/mitre/vulcan/master/docker-compose.prod.yml wget https://raw.githubusercontent.com/mitre/vulcan/master/setup-docker-secrets.sh chmod +x setup-docker-secrets.sh ./setup-docker-secrets.sh -docker compose up +docker compose -f docker-compose.prod.yml up ``` ### 2. Access Vulcan From 9d034887e61f98548f73ee298e23325765b1c568 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sat, 28 Feb 2026 16:11:12 -0500 Subject: [PATCH 326/428] test: SRG auto-detect backend specs + port registry doc - Request spec for POST /components/detect_srg endpoint - Service spec for SpreadsheetParser.peek_srg_ids - Add port-registry.md for multi-project development Authored by: Aaron Lippold --- docs/development/port-registry.md | 65 +++++++++ spec/requests/components_detect_srg_spec.rb | 131 ++++++++++++++++++ spec/services/spreadsheet_parser_spec.rb | 143 ++++++++++++++++++++ 3 files changed, 339 insertions(+) create mode 100644 docs/development/port-registry.md create mode 100644 spec/requests/components_detect_srg_spec.rb create mode 100644 spec/services/spreadsheet_parser_spec.rb diff --git a/docs/development/port-registry.md b/docs/development/port-registry.md new file mode 100644 index 000000000..010f9b653 --- /dev/null +++ b/docs/development/port-registry.md @@ -0,0 +1,65 @@ +# Multi-Project Port Registry + +When running multiple MITRE projects simultaneously, assign unique ports to avoid conflicts. + +## Default Behavior + +Every project defaults to PostgreSQL on port 5432 and the app on port 3000. This works out of the box for single-project development. Only set custom ports if you run multiple projects at once. + +## Port Assignments + +| Project | PORT (app) | DATABASE_PORT | Notes | +|---|---|---|---| +| vulcan-v2.x | 3000 | 5435 | | +| vulcan-v3.x | 3001 | 5436 | | +| vulcan-enterprise | 3002 | 5434 | already using 5434 | +| heimdall2 | 3010 | 5438 | | +| heimdall-clean | 3011 | 5439 | | +| memcord | -- | 5433 | already using 5433 | +| k8s helm testing | -- | 5440+ | via kubectl port-forward | + +## Setup + +Each project uses the same pattern: env vars with standard defaults. + +1. Copy `.env.example` to `.env` +2. Set `DATABASE_PORT` and `POSTGRES_PORT` to the same value from the table above +3. Set `PORT` for the app server +4. `docker compose up db -d` +5. `bin/rails db:prepare` (or equivalent) + +### Example `.env` (vulcan-v2.x) + +```bash +DATABASE_PORT=5435 +DATABASE_HOST=127.0.0.1 +DATABASE_GSSENCMODE=disable +POSTGRES_PORT=5435 +PORT=3000 +DB_SUFFIX=_v2 +``` + +## Environment Variable Naming Convention + +All MITRE projects follow `UPPERCASE_WITH_UNDERSCORES` using full descriptive words: + +| Variable | Used By | Purpose | +|---|---|---| +| `DATABASE_PORT` | `database.yml` | PostgreSQL client connection port | +| `DATABASE_HOST` | `database.yml` | PostgreSQL host (default: 127.0.0.1) | +| `DATABASE_GSSENCMODE` | `database.yml` | GSSAPI encryption mode (set to `disable` on macOS with Kerberos) | +| `DATABASE_URL` | `database.yml` | Full connection string (12-factor, takes precedence in production) | +| `POSTGRES_PORT` | `docker-compose.yml` | Docker host-side port mapping (must match DATABASE_PORT) | +| `POSTGRES_USER` | `docker-compose.yml` | Docker PostgreSQL init: username to create | +| `POSTGRES_PASSWORD` | `docker-compose.yml` | Docker PostgreSQL init: password to set | +| `POSTGRES_DB` | `docker-compose.yml` | Docker PostgreSQL init: database to create | +| `PORT` | `Procfile.dev` | App server (Puma/Rails) listen port | +| `DB_SUFFIX` | `database.yml` | Worktree isolation suffix (e.g., `_v2`) | + +**Why two port variables?** `DATABASE_PORT` is what Rails uses to connect. `POSTGRES_PORT` is what Docker uses to map the container's internal port 5432 to the host. Set both to the same value in `.env`. + +**Why not `PG*` shorthand?** PostgreSQL's libpq defines `PGPORT`, `PGHOST`, etc. but our project convention uses full descriptive words with underscores (`DATABASE_PORT`, `VULCAN_ENABLE_OIDC`, etc.) for consistency and readability across all MITRE projects. + +## OrbStack / k8s Note + +OrbStack's built-in Kubernetes can shadow port 5432 on the host. If `docker logs` shows no incoming connections but Rails reports connection errors, another PostgreSQL is intercepting traffic. Assign a different port. diff --git a/spec/requests/components_detect_srg_spec.rb b/spec/requests/components_detect_srg_spec.rb new file mode 100644 index 000000000..2bb51579c --- /dev/null +++ b/spec/requests/components_detect_srg_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENTS: +# POST /components/detect_srg should: +# 1. Accept a file upload (CSV or XLSX) +# 2. Parse SRGID column to detect which SecurityRequirementsGuide the file targets +# 3. Return { id, srg_id, title, version } on success +# 4. Return 422 with { error } when no file provided +# 5. Return 422 with { error } when no SRG IDs found in file +# 6. Return 422 with { error } when SRG IDs don't match any known SRG +# 7. Handle ambiguous case: SRG IDs from multiple SRGs → return 422 +# 8. Require authentication (redirect when not signed in) +# 9. Accept both 'SRGID' and 'SRG ID' header formats + +RSpec.describe 'Components - detect_srg' do + let_it_be(:user) { create(:user) } + + before do + Rails.application.reload_routes! + sign_in user + end + + def csv_upload(content, filename: 'test.csv') + file = Tempfile.new([filename, '.csv']) + file.write(content) + file.close + Rack::Test::UploadedFile.new(file.path, 'text/csv', false) + end + + describe 'POST /components/detect_srg' do + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to sign in' do + post '/components/detect_srg' + expect(response).to redirect_to(new_user_session_path) + end + end + + context 'when no file provided' do + it 'returns 422 with error' do + post '/components/detect_srg' + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('No file provided') + end + end + + context 'when file has no SRGID column' do + it 'returns 422 with error' do + upload = csv_upload("Name,Value\nfoo,bar\n") + post '/components/detect_srg', params: { file: upload } + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to match(/No SRG IDs found/) + end + end + + context 'when file has SRGID column but no matching SRG' do + it 'returns 422 with error' do + upload = csv_upload("SRGID,STIGID\nSRG-NONEXISTENT-000001,XX-000001\n") + post '/components/detect_srg', params: { file: upload } + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to match(/Could not identify/) + end + end + + context 'when file matches a known SRG' do + let!(:srg) { create(:security_requirements_guide) } + let!(:srg_rule) { create(:srg_rule, security_requirements_guide: srg) } + + it 'returns the matching SRG details' do + upload = csv_upload("SRGID,STIGID\n#{srg_rule.version},RHEL-09-000001\n") + post '/components/detect_srg', params: { file: upload } + + expect(response).to have_http_status(:success) + body = response.parsed_body + expect(body['id']).to eq(srg.id) + expect(body['title']).to eq(srg.title) + expect(body['version']).to eq(srg.version) + expect(body['srg_id']).to eq(srg.srg_id) + end + end + + context 'when file uses aliased header "SRG ID"' do + let!(:srg) { create(:security_requirements_guide) } + let!(:srg_rule) { create(:srg_rule, security_requirements_guide: srg) } + + it 'normalizes the header and detects the SRG' do + upload = csv_upload("SRG ID,STIG ID\n#{srg_rule.version},RHEL-09-000001\n") + post '/components/detect_srg', params: { file: upload } + + expect(response).to have_http_status(:success) + expect(response.parsed_body['id']).to eq(srg.id) + end + end + + context 'when SRG IDs map to multiple different SRGs (ambiguous)' do + let!(:srg_a) { create(:security_requirements_guide) } + let!(:srg_b) { create(:security_requirements_guide) } + let!(:rule_a) { create(:srg_rule, security_requirements_guide: srg_a) } + let!(:rule_b) { create(:srg_rule, security_requirements_guide: srg_b) } + + it 'returns 422 with ambiguity error' do + upload = csv_upload("SRGID,STIGID\n#{rule_a.version},XX-01\n#{rule_b.version},XX-02\n") + post '/components/detect_srg', params: { file: upload } + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to match(/multiple SRGs|ambiguous/i) + end + end + + context 'when file is unparseable' do + it 'returns 422 with error' do + bad_file = Tempfile.new(['bad', '.xlsx']) + bad_file.write('not a spreadsheet') + bad_file.close + upload = Rack::Test::UploadedFile.new(bad_file.path, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + + post '/components/detect_srg', params: { file: upload } + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to match(/No SRG IDs found/) + + bad_file.unlink + end + end + end +end diff --git a/spec/services/spreadsheet_parser_spec.rb b/spec/services/spreadsheet_parser_spec.rb new file mode 100644 index 000000000..f637d8307 --- /dev/null +++ b/spec/services/spreadsheet_parser_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENTS: +# SpreadsheetParser.peek_srg_ids(file) should: +# 1. Open a CSV or XLSX file and read the SRGID column +# 2. Handle both 'SRGID' and 'SRG ID' header formats (via HEADER_ALIASES) +# 3. Return an array of unique, non-blank SRG ID strings +# 4. NOT require an srg_id constructor parameter (class method, not instance) +# 5. Return [] for empty spreadsheets, missing SRGID column, or parse errors +# 6. Read only the SRGID column (lightweight — no full validation) + +RSpec.describe SpreadsheetParser do + include ImportConstants + + describe '.peek_srg_ids' do + def csv_tempfile(csv_content, extension: '.csv') + file = Tempfile.new(['test', extension]) + file.write(csv_content) + file.close + file + end + + context 'with valid CSV containing SRGID column' do + let(:csv) do + csv_tempfile(<<~CSV) + SRGID,STIGID,Severity,Requirement + SRG-OS-000001-GPOS-00001,RHEL-09-000001,medium,Some requirement + SRG-OS-000002-GPOS-00002,RHEL-09-000002,high,Another requirement + SRG-OS-000001-GPOS-00001,RHEL-09-000003,low,Duplicate SRG ID + CSV + end + + after { csv.unlink } + + it 'returns unique SRG IDs from the SRGID column' do + result = described_class.peek_srg_ids(csv.path) + expect(result).to contain_exactly( + 'SRG-OS-000001-GPOS-00001', + 'SRG-OS-000002-GPOS-00002' + ) + end + end + + context 'with aliased header "SRG ID" (space)' do + let(:csv) do + csv_tempfile(<<~CSV) + SRG ID,STIG ID,Severity,Title + SRG-OS-000480-GPOS-00227,RHEL-09-000100,medium,A requirement + CSV + end + + after { csv.unlink } + + it 'normalizes the header and returns SRG IDs' do + result = described_class.peek_srg_ids(csv.path) + expect(result).to eq(['SRG-OS-000480-GPOS-00227']) + end + end + + context 'with empty spreadsheet (header only)' do + let(:csv) do + csv_tempfile(<<~CSV) + SRGID,STIGID,Severity,Requirement + CSV + end + + after { csv.unlink } + + it 'returns an empty array' do + expect(described_class.peek_srg_ids(csv.path)).to eq([]) + end + end + + context 'with no SRGID column' do + let(:csv) do + csv_tempfile(<<~CSV) + Name,Value + foo,bar + CSV + end + + after { csv.unlink } + + it 'returns an empty array' do + expect(described_class.peek_srg_ids(csv.path)).to eq([]) + end + end + + context 'with blank SRGID values' do + let(:csv) do + csv_tempfile(<<~CSV) + SRGID,STIGID + SRG-OS-000001-GPOS-00001,RHEL-09-000001 + ,RHEL-09-000002 + ,RHEL-09-000003 + CSV + end + + after { csv.unlink } + + it 'filters out blank values' do + result = described_class.peek_srg_ids(csv.path) + expect(result).to eq(['SRG-OS-000001-GPOS-00001']) + end + end + + context 'with unparseable file' do + let(:bad_file) do + file = Tempfile.new(['test', '.xlsx']) + file.write('not a real spreadsheet') + file.close + file + end + + after { bad_file.unlink } + + it 'returns an empty array instead of raising' do + expect(described_class.peek_srg_ids(bad_file.path)).to eq([]) + end + end + end + + describe '#parse_and_validate' do + # Existing behavior — ensure we don't break it. + # SpreadsheetParser requires srg_id for full validation. + + let(:srg) { create(:security_requirements_guide) } + + it 'returns error for empty file' do + file = Tempfile.new(['empty', '.csv']) + file.write("SRGID,STIGID,Severity,Requirement,VulDiscussion,Status,Check,Fix,Status Justification,Artifact Description\n") + file.close + + result = described_class.new(file.path, srg.id).parse_and_validate + expect(result).to have_key(:error) + expect(result[:error]).to eq('Spreadsheet is empty') + + file.unlink + end + end +end From 4ef3d1fa1d7483d1becac130a3fd403d4c609f41 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 1 Mar 2026 08:47:34 -0500 Subject: [PATCH 327/428] fix: remove explicit secure cookie flag, let Rails handle it Remove explicit secure: from session_store.rb and devise.rb rememberable_options. ActionDispatch::SSL middleware marks ALL cookies secure when config.force_ssl is true. Setting it explicitly broke test/dev sessions and caused redirect loops when RAILS_FORCE_SSL=false. Mastodon/Discourse pattern. Regression test added. Authored by: Aaron Lippold --- config/initializers/devise.rb | 6 +-- config/initializers/session_store.rb | 7 +++- spec/requests/secure_cookie_spec.rb | 58 ++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 spec/requests/secure_cookie_spec.rb diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index bf581b58e..e7d0dfb50 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -150,10 +150,10 @@ # If true, extends the user's remember period when remembered via cookie. # config.extend_remember_period = false - # Options to be passed to the created cookie. For instance, you can set - # secure: true in order to force SSL only cookies. + # Options to be passed to the created cookie. + # NOTE: No `secure:` flag — ActionDispatch::SSL handles this automatically + # when config.force_ssl = true (see session_store.rb for full explanation). config.rememberable_options = { - secure: Rails.env.production?, httponly: true, same_site: :lax } diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 01f1e2951..ac35a319e 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -2,8 +2,13 @@ # AC-10: Server-side session store enables per-user session tracking. # Cookie-based sessions can't be invalidated server-side; ActiveRecord store can. +# +# NOTE: No `secure:` flag here. When config.force_ssl = true (production.rb), +# Rails' ActionDispatch::SSL middleware automatically marks ALL cookies secure. +# Setting `secure:` explicitly here would override Rails' built-in behavior and +# break sessions in test/development (which run over HTTP). +# See: Mastodon, Discourse use the same pattern. Rails.application.config.session_store :active_record_store, key: '_vulcan_session', - secure: ENV.fetch('RAILS_FORCE_SSL', 'true').downcase != 'false', httponly: true, same_site: :lax diff --git a/spec/requests/secure_cookie_spec.rb b/spec/requests/secure_cookie_spec.rb new file mode 100644 index 000000000..a797d6e62 --- /dev/null +++ b/spec/requests/secure_cookie_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Regression test: Secure cookie flag must NOT be set explicitly in session_store.rb +# or devise.rb. Rails' ActionDispatch::SSL middleware handles this automatically +# when config.force_ssl = true (production.rb). +# +# Setting `secure: true` explicitly breaks test/development (HTTP-only) and causes +# redirect loops in production when RAILS_FORCE_SSL=false (Docker quickstart). +# +# The correct pattern (used by Mastodon, Discourse): +# - session_store.rb: no `secure:` key +# - devise.rb rememberable_options: no `secure:` key +# - production.rb: config.force_ssl = ENV.fetch('RAILS_FORCE_SSL', 'true') != 'false' +# - ActionDispatch::SSL adds `; secure` to ALL cookies when force_ssl is active +# +# See: config/initializers/session_store.rb for full explanation. + +require 'rails_helper' + +RSpec.describe 'Secure cookie configuration' do + before do + Rails.application.reload_routes! + end + + describe 'session store' do + it 'does not explicitly set the secure flag (Rails handles this via force_ssl)' do + session_options = Rails.application.config.session_options + # The session store should NOT have an explicit :secure key. + # If someone adds `secure: true` to session_store.rb, this test fails, + # reminding them that ActionDispatch::SSL handles it automatically. + expect(session_options).not_to have_key(:secure), + 'session_store.rb must NOT set `secure:` explicitly. ' \ + 'ActionDispatch::SSL handles secure cookies when config.force_ssl = true. ' \ + 'Setting it explicitly breaks test/dev sessions and causes redirect loops.' + end + end + + describe 'devise rememberable cookie' do + it 'does not explicitly set the secure flag (Rails handles this via force_ssl)' do + rememberable_options = Devise.rememberable_options + expect(rememberable_options).not_to have_key(:secure), + 'devise.rb rememberable_options must NOT set `secure:` explicitly. ' \ + 'ActionDispatch::SSL handles secure cookies when config.force_ssl = true.' + end + end + + describe 'session works over HTTP in test environment' do + let(:user) { create(:user) } + + it 'maintains session across requests (proves cookies are not secure-only)' do + sign_in user + get root_path + expect(response).not_to redirect_to(new_user_session_path), + 'Signed-in user was redirected to login. This usually means the session cookie ' \ + 'has the secure flag set but tests run over HTTP, so the cookie is not sent back.' + end + end +end From 56bde6254ed30c59f53cfeee2b308cc14024a789 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 1 Mar 2026 08:47:48 -0500 Subject: [PATCH 328/428] feat: VULCAN_SEED_DEMO_DATA guard for production seeding Replace raise guard with env var check. Seeds now: - Always run in development/test (existing behavior) - Only run in production when VULCAN_SEED_DEMO_DATA=true - Create fallback demo admin if no admin exists - Use DoD 2222/15 compliant password (12qwaszx!@QWASZX) - Skip demo admin if admin:bootstrap already created one Document VULCAN_SEED_DEMO_DATA in env var docs. Authored by: Aaron Lippold --- ENVIRONMENT_VARIABLES.md | 14 +++++++++ db/seeds.rb | 31 ++++++++++++++++--- docs/getting-started/environment-variables.md | 14 +++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/ENVIRONMENT_VARIABLES.md b/ENVIRONMENT_VARIABLES.md index 6a5581620..c6b5c72d4 100644 --- a/ENVIRONMENT_VARIABLES.md +++ b/ENVIRONMENT_VARIABLES.md @@ -96,6 +96,20 @@ immediate use after `docker compose up`. For production, disable this and use `V condition attacks (similar to WordPress installer vulnerabilities). However, for production deployments, explicit admin configuration via environment variables is recommended. +### Demo/Evaluation Data + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VULCAN_SEED_DEMO_DATA` | Populate database with demo data in production | `false` | `true` | + +When `VULCAN_SEED_DEMO_DATA=true`, `db:seed` creates sample users, projects, and components for evaluation purposes. In development/test environments, demo data is always seeded. + +If no admin user exists (i.e., `VULCAN_ADMIN_EMAIL` was not set), a fallback demo admin is created: +- **Email**: `admin@example.com` +- **Password**: `12qwaszx\!@QWASZX` (DoD 2222/15 compliant) + +If an admin already exists from `admin:bootstrap`, the demo admin is skipped and only sample projects/users are created. + ### OIDC/OAuth (e.g., Okta, Auth0, Keycloak) **New in v2.2+**: Vulcan supports automatic endpoint discovery, reducing configuration from 8+ variables to just 4 essential ones. diff --git a/db/seeds.rb b/db/seeds.rb index 41a71123b..4ac5838a6 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -2,8 +2,32 @@ # rubocop:disable Rails/Output -# Populate the database for demonstration use. -raise 'This task is only for use in a development environment' unless Rails.env.development? || ENV.fetch('DISABLE_DATABASE_ENVIRONMENT_CHECK', false) +# Populate the database for demonstration/evaluation use. +# +# Environments: +# - development/test: always seeds (existing behavior) +# - production: only seeds when VULCAN_SEED_DEMO_DATA=true +# +# Usage: +# VULCAN_SEED_DEMO_DATA=true docker compose up # demo instance with sample data +# docker compose up # clean production (no demo data) +# +unless Rails.env.local? || ENV['VULCAN_SEED_DEMO_DATA'] == 'true' + puts 'Skipping seed data (set VULCAN_SEED_DEMO_DATA=true to populate demo data)' + return +end + +# DoD-compliant demo password: 16 chars, 2+ uppercase, 2+ lowercase, 2+ digits, 2+ special +DEMO_PASSWORD = '12qwaszx!@QWASZX' + +# Create demo admin only if no admin exists yet (admin:bootstrap may have already created one) +unless User.exists?(admin: true) + puts 'Creating demo admin (admin@example.com)...' + admin = User.new(name: 'Demo Admin', email: 'admin@example.com', password: DEMO_PASSWORD, admin: true) + admin.skip_confirmation! + admin.save! + puts " Demo admin created (password: #{DEMO_PASSWORD})" +end puts "Populating database for demo use:\n\n" @@ -41,11 +65,10 @@ def seed_xccdf(filepath) # Seeds for Users # # --------------- # puts 'Creating Users...' -User.create(name: FFaker::Name.name, email: 'admin@example.com', password: '1qaz!QAZ1qaz!QAZ', admin: true) users = [] 10.times do name = FFaker::Name.name - users << User.new(name: name, email: "#{name.split.join('.')}@example.com", password: '1qaz!QAZ1qaz!QAZ') + users << User.new(name: name, email: "#{name.split.join('.')}@example.com", password: DEMO_PASSWORD) end User.import(users) User.find_each do |user| diff --git a/docs/getting-started/environment-variables.md b/docs/getting-started/environment-variables.md index 3984bc6b7..e81cd98e6 100644 --- a/docs/getting-started/environment-variables.md +++ b/docs/getting-started/environment-variables.md @@ -96,6 +96,20 @@ immediate use after `docker compose up`. For production, disable this and use `V condition attacks (similar to WordPress installer vulnerabilities). However, for production deployments, explicit admin configuration via environment variables is recommended. +### Demo/Evaluation Data + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VULCAN_SEED_DEMO_DATA` | Populate database with demo data in production | `false` | `true` | + +When `VULCAN_SEED_DEMO_DATA=true`, `db:seed` creates sample users, projects, and components for evaluation purposes. In development/test environments, demo data is always seeded. + +If no admin user exists (i.e., `VULCAN_ADMIN_EMAIL` was not set), a fallback demo admin is created: +- **Email**: `admin@example.com` +- **Password**: `12qwaszx\!@QWASZX` (DoD 2222/15 compliant) + +If an admin already exists from `admin:bootstrap`, the demo admin is skipped and only sample projects/users are created. + ### OIDC/OAuth (e.g., Okta, Auth0, Keycloak) **New in v2.2+**: Vulcan supports automatic endpoint discovery, reducing configuration from 8+ variables to just 4 essential ones. From 2163a737dd9511f3e3e9f7a2a6bf5c2492f875cf Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 1 Mar 2026 21:30:54 -0500 Subject: [PATCH 329/428] docs: document database setup for dev, test, and production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup.md: Docker PostgreSQL (recommended) + local PostgreSQL setup - setup.md: parallel test database setup procedure (create → migrate → load_schema) - setup.md: Docker development section rewritten (db-only, full stack, multi-project) - testing.md: test database section with actual database.yml config - testing.md: parallel_tests rake task reference table Authored by: Aaron Lippold --- docs/development/setup.md | 109 +++++++++++++++++++++++++++++------- docs/development/testing.md | 45 +++++++++++++-- 2 files changed, 130 insertions(+), 24 deletions(-) diff --git a/docs/development/setup.md b/docs/development/setup.md index aee93c424..a37eaadc8 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -49,15 +49,54 @@ yarn install ### 3. Database Setup +#### Option A: Docker PostgreSQL (Recommended) + +```bash +# Start PostgreSQL container +docker compose up db -d + +# Wait for healthy status +docker compose ps db # should show "healthy" + +# Create and migrate development database +bin/rails db:prepare + +# Seed development data (optional — creates demo users, projects, SRGs, STIGs) +bin/rails db:seed +``` + +#### Option B: Local PostgreSQL + +```bash +# macOS +brew install postgresql@18 +brew services start postgresql@18 + +# Create and migrate +bin/rails db:prepare +bin/rails db:seed +``` + +#### Setting Up Parallel Test Databases + +`parallel_rspec` uses one database per CPU core. Set them up once after initial database creation: + ```bash -# Create database -rails db:create +# 1. Create parallel test databases (vulcan_vue_test, vulcan_vue_test2, ..., vulcan_vue_testN) +bundle exec rake parallel:create -# Run migrations -rails db:migrate +# 2. Migrate the primary test database (loads schema_migrations) +bin/rails db:migrate RAILS_ENV=test -# Seed development data (optional) -rails db:seed +# 3. Load schema into all parallel test databases +bundle exec rake parallel:load_schema +``` + +After schema changes (new migrations), re-sync parallel databases: + +```bash +bin/rails db:migrate RAILS_ENV=test +bundle exec rake parallel:load_schema ``` ### 4. Start Development Server @@ -406,28 +445,58 @@ rails server -p 3001 ## Docker Development -### Using Docker Compose +### Database-Only (Recommended for Local Dev) + +Use Docker for PostgreSQL while running Rails natively for faster iteration: ```bash -# Build containers -docker compose build +# Start PostgreSQL only +docker compose up db -d -# Start services -docker compose up +# Verify healthy +docker compose ps db + +# Set up databases (dev + parallel test) +bin/rails db:prepare +bundle exec rake parallel:create +bin/rails db:migrate RAILS_ENV=test +bundle exec rake parallel:load_schema + +# Run Rails natively +foreman start -f Procfile.dev +``` -# Run migrations -docker compose run web rails db:create db:migrate +### Full Docker Stack (Production-Like Testing) -# Access container -docker compose exec web bash +```bash +# Generate secrets +./setup-docker-secrets.sh + +# Build and start everything +docker compose -f docker-compose.prod.yml up --build + +# Database setup runs automatically via docker-entrypoint +# First user becomes admin when VULCAN_FIRST_USER_ADMIN=true (default in Docker) +``` + +### Multi-Project Setup + +When running multiple MITRE projects simultaneously, assign unique ports to avoid conflicts. See `docs/development/port-registry.md` for port assignments. + +```bash +# Example .env for vulcan-v2.x alongside other projects +DATABASE_PORT=5435 +POSTGRES_PORT=5435 +PORT=3000 +DATABASE_GSSENCMODE=disable ``` -### Docker Development Tips +### Docker Tips -1. Use volumes for code hot-reload -2. Separate services for web, db, redis -3. Use .dockerignore for faster builds -4. Override configs with docker-compose.override.yml +1. Use `docker compose up db -d` (database-only) for fastest development cycle +2. Use `.dockerignore` for faster builds (excludes docs/, downloads/, coverage/) +3. Production image uses multi-stage build with jemalloc (~596MB) +4. `docker-compose.prod.yml` supports Caddy or nginx reverse proxy profiles ## Performance Optimization diff --git a/docs/development/testing.md b/docs/development/testing.md index 04b19a3b9..e98143202 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -315,12 +315,49 @@ end ```yaml # config/database.yml test: - adapter: postgresql - database: vulcan_test - pool: 5 - timeout: 5000 + <<: *default + database: vulcan_vue_test<%= ENV['DB_SUFFIX'] %><%= ENV['TEST_ENV_NUMBER'] %> ``` +`TEST_ENV_NUMBER` is set automatically by `parallel_tests` — each worker gets a suffix (blank, 2, 3, ..., N) creating databases `vulcan_vue_test`, `vulcan_vue_test2`, etc. + +`DB_SUFFIX` is optional, used for worktree isolation (e.g., `_v2` → `vulcan_vue_test_v2`). + +### Initial Setup (One-Time) + +```bash +# 1. Ensure PostgreSQL is running (Docker or local) +docker compose up db -d + +# 2. Create all parallel test databases +bundle exec rake parallel:create + +# 3. Migrate the primary test database +bin/rails db:migrate RAILS_ENV=test + +# 4. Load schema into all parallel databases +bundle exec rake parallel:load_schema +``` + +### After Schema Changes + +When you add new migrations, re-sync the parallel databases: + +```bash +bin/rails db:migrate RAILS_ENV=test +bundle exec rake parallel:load_schema +``` + +### Key Rake Tasks + +| Task | Purpose | +|---|---| +| `parallel:create` | Create parallel test databases | +| `parallel:load_schema` | Load `db/schema.rb` into all parallel databases | +| `parallel:prepare` | Dump + load schema (requires migrated primary DB) | +| `parallel:migrate` | Run pending migrations on all parallel databases | +| `parallel:drop` | Drop all parallel test databases | + ### Database Cleaner Setup ```ruby From 81359fa728de6408caa00c9c2411e53868a43d08 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 1 Mar 2026 21:41:05 -0500 Subject: [PATCH 330/428] feat: enable banner and consent modal on review apps - Classification banner: "Review Preview" with steel blue (#4a6fa5) - Consent modal: general terms of use text, shown on first visit - Both configured via app.json review environment env vars Authored by: Aaron Lippold --- app.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app.json b/app.json index cf49aa2f9..c17362ada 100644 --- a/app.json +++ b/app.json @@ -55,6 +55,27 @@ }, "VULCAN_PROJECT_CREATE_PERMISSION_ENABLED": { "value": "true" + }, + "VULCAN_BANNER_ENABLED": { + "value": "true" + }, + "VULCAN_BANNER_TEXT": { + "value": "Review Preview" + }, + "VULCAN_BANNER_BACKGROUND_COLOR": { + "value": "#4a6fa5" + }, + "VULCAN_BANNER_TEXT_COLOR": { + "value": "#ffffff" + }, + "VULCAN_CONSENT_ENABLED": { + "value": "true" + }, + "VULCAN_CONSENT_TITLE": { + "value": "Terms of Use" + }, + "VULCAN_CONSENT_CONTENT": { + "value": "This is a preview instance of Vulcan for review purposes. By continuing, you acknowledge that this environment may be reset at any time and should not be used for production work. All activity may be monitored." } }, "scripts": { From 14a1d7ce5350d1939564a5f9b3d3cda2693ec147 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 1 Mar 2026 22:53:29 -0500 Subject: [PATCH 331/428] feat: AC-8 server-side consent tracking with configurable TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace localStorage-based consent acknowledgment with server-side Rails session tracking per NIST AC-8. Consent is now tied to the authentication lifecycle — clears on logout, session timeout, or browser close. - Add POST /consent/acknowledge endpoint (ConsentController) - Add consent_required? helper with parse_duration for TTL - Add VULCAN_CONSENT_TTL config (default 0 = per-session, DoD compliant) - Update ConsentModal.vue: POST via axios instead of localStorage - Pass server-side required flag to navbar via layout - 9 request specs + 12 Vue unit tests, all passing - Update .env examples and app.json with TTL default Authored by: Aaron Lippold --- .env.example | 5 +- .env.production.example | 5 +- app.json | 3 + app/controllers/application_controller.rb | 26 +++ app/controllers/consent_controller.rb | 13 ++ .../components/shared/ConsentModal.vue | 36 ++-- app/views/layouts/application.html.haml | 2 +- config/routes.rb | 3 + config/vulcan.default.yml | 1 + .../components/shared/ConsentModal.spec.js | 71 ++++--- spec/requests/authorization_coverage_spec.rb | 5 +- spec/requests/consent_spec.rb | 174 ++++++++++++++++++ 12 files changed, 284 insertions(+), 60 deletions(-) create mode 100644 app/controllers/consent_controller.rb create mode 100644 spec/requests/consent_spec.rb diff --git a/.env.example b/.env.example index 88d849f4a..036b22a56 100644 --- a/.env.example +++ b/.env.example @@ -138,12 +138,15 @@ VULCAN_BANNER_ENABLED=false # VULCAN_BANNER_TEXT_COLOR=#ffffff # Consent/terms-of-use modal — blocks access until user clicks "I Agree" -# Increment VULCAN_CONSENT_VERSION to re-prompt all users +# Acknowledgment is tracked server-side in the Rails session (AC-8 compliant). +# Increment VULCAN_CONSENT_VERSION to re-prompt all users. # Content supports Markdown formatting (bold, lists, links, etc.) VULCAN_CONSENT_ENABLED=false # VULCAN_CONSENT_VERSION=1 # VULCAN_CONSENT_TITLE=Terms of Use # VULCAN_CONSENT_CONTENT=By using this system you agree to the **acceptable use policy**. +# How long consent remains valid: 0 = per-session (DoD default), or e.g. 24h, 12h, 30m +VULCAN_CONSENT_TTL=0 # ============================================================================= # PASSWORD POLICY (Optional — DoD-aligned defaults) diff --git a/.env.production.example b/.env.production.example index db81fe297..c65444608 100644 --- a/.env.production.example +++ b/.env.production.example @@ -112,12 +112,15 @@ VULCAN_BANNER_ENABLED=false # VULCAN_BANNER_TEXT_COLOR=#ffffff # Consent/terms-of-use modal — blocks access until user clicks "I Agree" -# Increment VULCAN_CONSENT_VERSION to re-prompt all users +# Acknowledgment is tracked server-side in the Rails session (AC-8 compliant). +# Increment VULCAN_CONSENT_VERSION to re-prompt all users. # Content supports Markdown formatting (bold, lists, links, etc.) VULCAN_CONSENT_ENABLED=false # VULCAN_CONSENT_VERSION=1 # VULCAN_CONSENT_TITLE=Terms of Use # VULCAN_CONSENT_CONTENT=By using this system you agree to the **acceptable use policy**. +# How long consent remains valid: 0 = per-session (DoD default), or e.g. 24h, 12h, 30m +VULCAN_CONSENT_TTL=0 # ============================================================================= # OPTIONAL: Password Policy (DoD-aligned defaults — usually no changes needed) diff --git a/app.json b/app.json index c17362ada..c10601ed7 100644 --- a/app.json +++ b/app.json @@ -76,6 +76,9 @@ }, "VULCAN_CONSENT_CONTENT": { "value": "This is a preview instance of Vulcan for review purposes. By continuing, you acknowledge that this environment may be reset at any time and should not be used for production work. All activity may be monitored." + }, + "VULCAN_CONSENT_TTL": { + "value": "0" } }, "scripts": { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 96391c955..88c63fc3f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,6 +10,21 @@ class ApplicationController < ActionController::Base before_action :check_access_request_notifications before_action :check_locked_user_notifications + # AC-8: Determines if the current user must acknowledge consent. + # Returns true when consent is enabled and the session has no valid acknowledgment. + def consent_required? + return false unless Settings.consent&.enabled + + acknowledged_at = session[:consent_acknowledged_at] + return true if acknowledged_at.blank? + + ttl = Settings.consent.respond_to?(:ttl) ? Settings.consent.ttl : nil + return false if ttl.blank? || ttl.to_s == '0' + + Time.zone.parse(acknowledged_at) + parse_duration(ttl) < Time.current + end + helper_method :consent_required? + rescue_from NotAuthorizedError, with: :not_authorized rescue_from StandardError, with: :helpful_errors unless Rails.env.development? @@ -133,6 +148,17 @@ def send_smtp_notification(mailer, action, *) private + # Parses duration strings like "1h", "30m", "24h", "3600" into seconds. + def parse_duration(value) + str = value.to_s.strip + case str + when /\A(\d+)h\z/i then ::Regexp.last_match(1).to_i.hours + when /\A(\d+)m\z/i then ::Regexp.last_match(1).to_i.minutes + when /\A(\d+)s?\z/ then ::Regexp.last_match(1).to_i.seconds + else 0.seconds + end + end + # Determine the slack channel(s) and user id to which the slack notification should be sent. def find_slack_channel(object, notification_type) channels = [] diff --git a/app/controllers/consent_controller.rb b/app/controllers/consent_controller.rb new file mode 100644 index 000000000..1bfdf6aac --- /dev/null +++ b/app/controllers/consent_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Handles server-side consent acknowledgment for NIST AC-8 compliance. +# Stores acknowledgment timestamp in the Rails session, tying consent +# to the authentication lifecycle rather than browser localStorage. +class ConsentController < ApplicationController + before_action :authenticate_user! + + def acknowledge + session[:consent_acknowledged_at] = Time.current.iso8601 + head :ok + end +end diff --git a/app/javascript/components/shared/ConsentModal.vue b/app/javascript/components/shared/ConsentModal.vue index 456cf80d2..6940d6e3f 100644 --- a/app/javascript/components/shared/ConsentModal.vue +++ b/app/javascript/components/shared/ConsentModal.vue @@ -22,8 +22,7 @@ diff --git a/app/javascript/components/components/MembersModal.vue b/app/javascript/components/components/MembersModal.vue index d1367ee48..dec2f7553 100644 --- a/app/javascript/components/components/MembersModal.vue +++ b/app/javascript/components/components/MembersModal.vue @@ -135,7 +135,7 @@ diff --git a/config/routes.rb b/config/routes.rb index 331038e96..bca9c6a8c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -54,6 +54,8 @@ # Must be before /:id/:stig_id catch-all to avoid route collision get '/components/bulk_export/:type', to: 'components#bulk_export' + # Component activity history (B5 reactivity fix) — MUST be before :stig_id catch-all + get '/components/:id/histories', to: 'components#histories' # Add deep linking to specific rule (stig_id of format XXXX-XX-000000) get '/components/:id/:stig_id', to: 'components#show' @@ -78,6 +80,8 @@ patch '/components/:id/apply_spreadsheet_update', to: 'components#apply_spreadsheet_update' # Find post '/components/:id/find', to: 'components#find' + # Project activity history (Phase 4) + get '/projects/:id/histories', to: 'projects#histories' # Export project get '/projects/:id/export/:type', to: 'projects#export' # Create new project from backup archive (before resources :projects to avoid id catch) diff --git a/spec/javascript/components/shared/ControlsSidepanels.spec.js b/spec/javascript/components/shared/ControlsSidepanels.spec.js new file mode 100644 index 000000000..b94e95771 --- /dev/null +++ b/spec/javascript/components/shared/ControlsSidepanels.spec.js @@ -0,0 +1,112 @@ +/** + * ControlsSidepanels — B5 Reactivity Tests + * + * REQUIREMENT: When a rule is saved or its status changes, the Activity and + * Reviews sidepanels must refresh to show the latest data. The frontend emits + * "refresh:activity" on $root after rule fetch success. ControlsSidepanels + * listens for this event and re-fetches component histories. + */ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { shallowMount } from "@vue/test-utils"; +import { localVue } from "@test/testHelper"; +import ControlsSidepanels from "@/components/shared/ControlsSidepanels.vue"; +import axios from "axios"; + +vi.mock("axios"); + +function createWrapper(props = {}) { + return shallowMount(ControlsSidepanels, { + localVue, + propsData: { + component: { + id: 8, + name: "Test Component", + version: 1, + release: 1, + title: "Test STIG", + description: "Test description", + histories: [], + reviews: [], + memberships: [], + metadata: {}, + additional_questions: [], + }, + effectivePermissions: "admin", + ...props, + }, + }); +} + +describe("ControlsSidepanels", () => { + let wrapper; + + afterEach(() => { + if (wrapper) wrapper.destroy(); + vi.restoreAllMocks(); + }); + + describe("B5: refresh:activity reactivity", () => { + it("listens for refresh:activity event on $root", () => { + wrapper = createWrapper(); + // The component should have registered the listener + expect(wrapper.vm.$root._events["refresh:activity"]).toBeTruthy(); + }); + + it("fetches component histories when refresh:activity is emitted", async () => { + const mockHistories = [ + { + id: 1, + action: "update", + name: "Test User", + audited_changes: [{ field: "title", prev_value: "Old", new_value: "New" }], + created_at: "2026-03-05T00:00:00Z", + }, + ]; + axios.get.mockResolvedValueOnce({ data: mockHistories }); + + wrapper = createWrapper(); + wrapper.vm.$root.$emit("refresh:activity"); + + // Wait for async axios call + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(axios.get).toHaveBeenCalledWith("/components/8/histories"); + }); + + it("updates local histories data after fetch", async () => { + const mockHistories = [ + { + id: 99, + action: "update", + name: "Test User", + audited_changes: [], + created_at: "2026-03-05T00:00:00Z", + }, + ]; + axios.get.mockResolvedValueOnce({ data: mockHistories }); + + wrapper = createWrapper(); + // Initial histories empty + expect(wrapper.vm.localHistories).toEqual([]); + + wrapper.vm.$root.$emit("refresh:activity"); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(wrapper.vm.localHistories).toEqual(mockHistories); + }); + + it("cleans up listener on destroy", () => { + wrapper = createWrapper(); + const root = wrapper.vm.$root; + expect(root._events["refresh:activity"].length).toBe(1); + + wrapper.destroy(); + // After destroy, listener should be removed (Vue 2 may leave empty array) + const listeners = root._events["refresh:activity"]; + expect(!listeners || listeners.length === 0).toBe(true); + wrapper = null; // prevent double destroy in afterEach + }); + }); +}); diff --git a/spec/requests/projects_histories_spec.rb b/spec/requests/projects_histories_spec.rb new file mode 100644 index 000000000..0f41f24c7 --- /dev/null +++ b/spec/requests/projects_histories_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# REQUIREMENT: Project-level activity view (Phase 4). +# Projects must expose a histories endpoint that includes: +# - Project metadata changes +# - Component changes (via has_associated_audits) +# - Membership changes (via associated_with) +RSpec.describe 'Project Histories' do + let_it_be(:user) { create(:user, admin: true) } + let_it_be(:project) { create(:project) } + let_it_be(:component) { create(:component, project: project) } + + before do + Rails.application.reload_routes! + sign_in user + Membership.create!(user: user, membership: project, role: 'admin') + end + + describe 'GET /projects/:id/histories' do + it 'requires authentication' do + sign_out user + get "/projects/#{project.id}/histories", + headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unauthorized) + .or redirect_to(new_user_session_path) + end + + it 'returns an array of formatted audit entries' do + # Generate an audit by updating the project + project.update!(name: 'Updated Project Name', audit_comment: 'Test project history') + + get "/projects/#{project.id}/histories", + headers: { 'Accept' => 'application/json' } + + expect(response).to have_http_status(:success) + json = response.parsed_body + expect(json).to be_an(Array) + expect(json.length).to be > 0 + + entry = json.last + expect(entry).to have_key('action') + expect(entry).to have_key('audited_changes') + expect(entry).to have_key('created_at') + end + + it 'includes component changes in project history' do + # Component is associated_with project, so its audits should bubble up + rule = component.rules.first + rule&.update!(title: 'Updated for project history test') + + get "/projects/#{project.id}/histories", + headers: { 'Accept' => 'application/json' } + + expect(response).to have_http_status(:success) + json = response.parsed_body + expect(json).to be_an(Array) + end + + it 'returns error for non-existent project' do + get '/projects/999999/histories', + headers: { 'Accept' => 'application/json' } + expect(response.status).to be >= 400 + end + end +end From 01971119fe706a5d2267a9f9d1e8fcd3764edeaf Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 5 Mar 2026 16:50:41 -0500 Subject: [PATCH 379/428] fix: Actions toolbar visible on all editor tabs B6: moved RuleActionsToolbar above b-tabs so Save, Clone, Delete, Lock, and Info buttons are visible on Test Script and InSpec Control tabs too. Authored by: Aaron Lippold --- .../components/rules/RuleEditor.vue | 32 +++++++++---------- .../components/rules/RuleEditor.spec.js | 22 +++++++++++++ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/app/javascript/components/rules/RuleEditor.vue b/app/javascript/components/rules/RuleEditor.vue index d807d5182..bc1c79cb3 100644 --- a/app/javascript/components/rules/RuleEditor.vue +++ b/app/javascript/components/rules/RuleEditor.vue @@ -1,23 +1,23 @@ @@ -122,15 +123,15 @@