Skip to content

Vulcan v2.3.6

Released: 2026-05-08

Highlights

  • UBI9 base image — container is now built on registry.access.redhat.com/ubi9/ubi-minimal:9.7, with Ruby and jemalloc compiled from source. Aligns Vulcan's container with Iron Bank / DISA compliance requirements. Breaking: Docker Compose volume layout changed — see Migration below.
  • Public-comment review workflow — DISA-style comment lifecycle (pending → concur / concur-w/-comment / non-concur / duplicate / informational / needs-clarification → adjudicated), triage modal with response composer, project- and component-level triage tables, per-user "My Comments" page, and a disposition-matrix CSV export.
  • Viewer-can-comment — project viewers can now post comments and reactions. Higher-tier actions (Save / Approve / Request Changes / Lock) remain gated.
  • Comment reactions (👍/👎) on rule comments and replies, with hover/focus/tap-accessible reactor name list.
  • Structured permission-denied responses403 Forbidden JSON bodies now include the project administrator contacts so users know exactly who to ask for access.

Migration

⚠️ Read this section before upgrading any existing deployment. Two breaking changes ship in v2.3.6.

1. Docker base image (UBI9)

The Vulcan Docker image is now based on Red Hat UBI 9 minimal instead of the Debian-based ruby:3.4.9-slim. Ruby is compiled from source. The image is functionally compatible — environment variables, exposed ports, entrypoint, and CMD are unchanged — so most deployments only need to pull the new tag and restart.

If you build custom downstream images that extend the Vulcan image with apt-get package installs, those commands no longer work. UBI9 uses microdnf (or dnf in the full UBI image). Replace your custom layers accordingly.

2. PostgreSQL volume mount path

PostgreSQL 18 recommends mounting at /var/lib/postgresql (the parent directory) rather than /var/lib/postgresql/data, so that future pg_upgrade --link operations don't cross mount boundaries. The shipped docker-compose.yml reflects this:

yaml
# v2.3.5 (old)
volumes:
  - vulcan_dbdata:/var/lib/postgresql/data

# v2.3.6 (new)
volumes:
  - vulcan_dbdata:/var/lib/postgresql

Existing deployments with a populated vulcan_dbdata volume must migrate before pulling the new compose file. If you don't, Postgres will see an empty /var/lib/postgresql/data directory inside the volume and run initdb on startup, creating a fresh empty cluster — your existing data will be invisible to the new container.

Recommended migration (dump + restore):

bash
# 1. While still running v2.3.5: dump the database
docker compose exec -T db pg_dumpall -U postgres > vulcan-dbdata-pre-upgrade.sql

# 2. Pull the v2.3.6 release
git pull   # (or update your deployment's docker-compose.yml manually)

# 3. Bring everything down and remove the old volume
docker compose down -v

# 4. Bring up just the database; it will initdb a fresh cluster at the new path
docker compose up -d db
sleep 10   # let Postgres finish initialization

# 5. Restore your data
docker compose exec -T db psql -U postgres < vulcan-dbdata-pre-upgrade.sql

# 6. Bring up the rest of the stack
docker compose up -d

Alternate path (pin the old volume mount): if dumping/restoring isn't workable in your environment, you can keep the old mount path by overriding it locally:

yaml
# docker-compose.override.yml
services:
  db:
    volumes: !override
      - vulcan_dbdata:/var/lib/postgresql/data

Your data stays in place. You give up future pg_upgrade --link support, but pg_dumpall-based major-version upgrades still work fine.

Added

  • Comment reactions (👍/👎) on rule comments and replies. Reactions render as counts on each comment in the rule editor pullout, the comment thread, and the triage modal; click the people-icon to see reactor names (works on hover, focus, and tap — accessible to keyboard and touch users). Reactions are merged into the parent comment's Thread Replies cell in the disposition-matrix CSV export (alongside text replies, in chronological order) as [name · timestamp] reacted thumbs-up entries. Audited via vulcan_audited so the toggle history is preserved.
  • Public-comment review workflow — full lifecycle for the DISA STIG public-comment process:
    • comment_phase on Component (open / closed-adjudicating / closed-finalized) drives whether new comments are accepted and whether triage actions are open.
    • Triage states: pending, concur, concur_with_comment, non_concur, duplicate, informational, needs_clarification, adjudicated, withdrawn.
    • Triage / adjudicate / reopen / withdraw / update endpoints under PATCH /reviews/:id/....
    • Component-level (/components/:id/triage) and project-level (/projects/:id/triage) aggregate triage tables with filtering by status / section / rule / author / search.
    • Per-user "My Comments" page on the user profile, scoped to comments the user authored.
    • Comment composer with section-tagging, dedup banner showing related comments on the same rule, and reply-aware threading.
    • Section-comment icons in the rule editor (per-section comment composer entry points).
    • Disposition-matrix CSV export at /components/:id/export/disposition_csv (admin opt-in for include_email).
  • Comment phase fieldset on the Edit Component modal.
  • Comment-period banner on the Component page when a public comment window is open.
  • Per-rule "Comments" column in the rule navigator with badge for pending counts.
  • Per-project "Comments" badge in the projects-list page with smart deep-link to the relevant component.
  • Re-open and re-triage flows for adjudicated comments, including admin force-withdraw.
  • Cross-rule "satisfies" panel comment counts.
  • Mark-as-duplicate action on comments (with canonical-comment picker + circular-duplicate guard).
  • Edit-comment-section action so triagers can re-tag a mis-tagged section.
  • Rate limits on reaction endpoints: 60 toggles/min/user (POST) and 300 hover-fetches/min/user (GET) via Rack::Attack, with IP fallback for unauthenticated traffic.
  • Rate limits on comment-post endpoints, with 4000-character cap on action='comment' Reviews.
  • Review::VALID_ACTIONS allowlist + inclusion validator on Review#action so unknown action strings no longer save silently as state-mutating no-ops.
  • AlertMixin now renders structured permission-denied responses as a "Permission denied" toast that lists the project administrators (name and email) the user should contact for access — no more silent or generic failures on rejected actions.
  • Triage / disposition i18n + vocabulary parity tests in config/locales/en.yml and app/javascript/constants/triageVocabulary.js.
  • Lifecycle columns on reviews (triage_status, triage_set_by_id, triage_set_at, adjudicated_at, adjudicated_by_id, responding_to_review_id, section, duplicate_of_review_id) with appropriate FK constraints and the partial index supporting the triage queue's natural query shape.
  • comment_phase and comment_period_starts_at / comment_period_ends_at columns on components.

Changed

  • BREAKING (Docker): Base image changed from ruby:3.4.9-slim (Debian) to registry.access.redhat.com/ubi9/ubi-minimal:9.7 (Red Hat UBI 9). Ruby is now compiled from source in the build stage. jemalloc is compiled from source and re-enabled via LD_PRELOAD.
  • BREAKING (Docker Compose): PostgreSQL 18 volume mount moved from /var/lib/postgresql/data to /var/lib/postgresql. See Migration above.
  • Project viewers can now post comments on rules. Previously the viewer role was strictly read-only; it now grants read + comment access. To restrict commenting, remove the user's project membership.
  • Authorization rejection responses for JSON requests now return a structured 403 Forbidden body ({ error: 'permission_denied', message, admins: [...], toast }) instead of 500 Internal Server Error. The legacy toast shape is kept alongside so existing AlertMixin consumers keep working unchanged.
  • Component "Reviews" slideover replaced by the full ComponentComments triage table (paginated, filterable, sortable). Old slideover UX removed.
  • All Docker-image-relevant CI workflow actions pinned to actions/checkout@v6.0.2; the docker job runs Compose-up healthcheck verification.
  • /rails source-tree permissions in the production Docker image are tightened (source files 440, bin scripts 550, dirs 550); only db log storage tmp are user-writable at runtime.
  • Reviews controller create action now wraps Review.transaction and rescues ActiveRecord::StatementInvalid to return a graceful 422 with a danger toast instead of leaking 500 on DB errors.

Fixed

  • rescue_from ordering bug in ApplicationControllerNotAuthorizedError was being shadowed by the catch-all StandardError rescue (ActiveSupport::Rescuable matches handlers via reverse_each, so the LAST-declared rescue wins). The dedicated not_authorized handler was effectively dead code in any non-development environment for JSON requests, surfacing every unauthorized action as 500 instead of the proper 401/403. Reordered so the specific rescue wins.
  • Viewer role was incorrectly able to trigger request_review on unlocked, not-under-review rules (Copilot review of PR #717). Per-action role gate now enforced via ACTION_PERMISSIONS map in the Review model.
  • DoubleRenderError in ReviewsController#lock_controls when partial rule-locking failed.
  • Several backend transaction wraps to prevent orphaned-on-mid-cleanup-failure states (rules#destroy, components#destroy, lock_sections, rule_satisfactions).
  • Vulcan audit attribution chain now scopes correctly across imported reviews.
  • Comment composer modal section selector now uses FilterDropdown (eliminates native-select clipping in long lists).
  • Triage table rule + component links use full page loads (avoids the Vue-pack-vs-Turbolinks load-order race that left blank pages).
  • Backend(1) reaction-throttle CI flake — wrapped reaction-throttle specs in freeze_time so the 60-request loop doesn't span a Rack::Attack 60s bucket boundary on slow runners.
  • SonarCloud reliability: removed two string-literal raises in spec helpers (raise StandardError, ...).

Upgrade

  1. Read the Migration section above carefully. The Postgres volume change will silently lose data on existing deployments if not handled.
  2. Pull the new image / update your deployment's docker-compose.yml.
  3. Standard upgrade: bundle exec rails db:migrate if running outside Docker; otherwise the entrypoint runs db:prepare automatically.
  4. Rebuild assets: yarn install && yarn build (only relevant for non-Docker source-based deploys).
  5. Run tests: bundle exec parallel_rspec spec/ && yarn test:unit.

Contributors

Aaron Lippold (lippold@gmail.com), Will Dower (will@dower.dev), Amndeep Singh Mann (amann@mitre.org), with Claude (Anthropic) assisting on PR-717 and PR-718.

Part of the MITRE Security Automation Framework (SAF)