diff --git a/app/assets/javascripts/foreman_openscap/reports.js b/app/assets/javascripts/foreman_openscap/reports.js index d4a645ca..ad63be36 100644 --- a/app/assets/javascripts/foreman_openscap/reports.js +++ b/app/assets/javascripts/foreman_openscap/reports.js @@ -4,3 +4,8 @@ function showReportDetails(log_id, event) { showDetails.is(':visible') ? $(event).find('span').attr('class', 'glyphicon glyphicon-collapse-up') : $(event).find('span').attr('class', 'glyphicon glyphicon-collapse-down'); } +function showRemediationWizard(log_id) { + var wizard_button = $('#openscapRemediationWizardButton'); + wizard_button.attr('data-log-id', log_id); + wizard_button.click(); +} diff --git a/app/controllers/arf_reports_controller.rb b/app/controllers/arf_reports_controller.rb index 4c3e70c5..65c2d9eb 100644 --- a/app/controllers/arf_reports_controller.rb +++ b/app/controllers/arf_reports_controller.rb @@ -2,7 +2,7 @@ class ArfReportsController < ApplicationController include Foreman::Controller::AutoCompleteSearch include ForemanOpenscap::ArfReportsControllerCommonExtensions - before_action :find_arf_report, :only => %i[show show_html destroy parse_html parse_bzip download_html] + before_action :find_arf_report, :only => %i[show show_html destroy parse_html parse_bzip download_html show_log] before_action :find_multiple, :only => %i[delete_multiple submit_delete_multiple] def model_of_controller @@ -72,6 +72,27 @@ def submit_delete_multiple end end + def show_log + return not_found unless @arf_report # TODO: use Message/Log model directly instead? + + log = @arf_report.logs.find(params[:log_id]) + return not_found unless log + + respond_to do |format| + format.json do + render json: { + log: { + source: log.source, + message: { + value: log.message.value, + fixes: log.message.fixes, + } + }, + }, status: :ok + end + end + end + private def find_arf_report @@ -99,7 +120,7 @@ def find_multiple def action_permission case params[:action] - when 'show_html', 'parse_html', 'parse_bzip', 'download_html' + when 'show_html', 'parse_html', 'parse_bzip', 'download_html', 'show_log' :view when 'delete_multiple', 'submit_delete_multiple' :destroy diff --git a/app/helpers/arf_reports_helper.rb b/app/helpers/arf_reports_helper.rb index 4c7d792a..2cc1c09d 100644 --- a/app/helpers/arf_reports_helper.rb +++ b/app/helpers/arf_reports_helper.rb @@ -62,9 +62,22 @@ def reported_info(arf_report) msg.html_safe end - def host_search_by_rule_result_buttons(source) - action_buttons(display_link_if_authorized(_('Hosts failing this rule'), hash_for_hosts_path(:search => "fails_xccdf_rule = #{source}")), - display_link_if_authorized(_('Hosts passing this rule'), hash_for_hosts_path(:search => "passes_xccdf_rule = #{source}")), - display_link_if_authorized(_('Hosts othering this rule'), hash_for_hosts_path(:search => "others_xccdf_rule = #{source}"))) + def host_search_by_rule_result_buttons(log) + buttons = [ + display_link_if_authorized(_('Hosts failing this rule'), hash_for_hosts_path(:search => "fails_xccdf_rule = #{log.source}")), + display_link_if_authorized(_('Hosts passing this rule'), hash_for_hosts_path(:search => "passes_xccdf_rule = #{log.source}")), + display_link_if_authorized(_('Hosts othering this rule'), hash_for_hosts_path(:search => "others_xccdf_rule = #{log.source}")), + ] + if log.result == 'fail' && log.message.fixes.present? + buttons << link_to_function_if_authorized(_('Remediation'), "showRemediationWizard(#{log.id})", hash_for_show_log_arf_report_path(id: log.report.id)) + end + action_buttons(buttons) + end + + def supported_remediation_snippets + snippets = [] + snippets << 'urn:xccdf:fix:script:sh' if ForemanOpenscap.with_remote_execution? + snippets << 'urn:xccdf:fix:script:ansible' if ForemanOpenscap.with_ansible? + snippets end end diff --git a/app/models/foreman_openscap/arf_report.rb b/app/models/foreman_openscap/arf_report.rb index 10db620d..164586ca 100644 --- a/app/models/foreman_openscap/arf_report.rb +++ b/app/models/foreman_openscap/arf_report.rb @@ -131,14 +131,16 @@ def self.create_arf(asset, proxy, params) :severity => log[:severity], :description => newline_to_space(log[:description]), :rationale => newline_to_space(log[:rationale]), - :scap_references => references_links(log[:references]) + :scap_references => references_links(log[:references]), + :fixes => fixes(log[:fixes]) } else msg = Message.new(:value => N_(log[:title]), :severity => log[:severity], :description => newline_to_space(log[:description]), :rationale => newline_to_space(log[:rationale]), - :scap_references => references_links(log[:references])) + :scap_references => references_links(log[:references]), + :fixes => fixes(log[:fixes])) end msg.save! end @@ -222,12 +224,19 @@ def self.references_links(references) html_links.join(', ') end + def self.fixes(raw_fixes) + return if raw_fixes.empty? + + JSON.fast_generate(raw_fixes) + end + def self.update_msg_with_changes(msg, incoming_data) msg.severity = incoming_data['severity'] msg.description = incoming_data['description'] msg.rationale = incoming_data['rationale'] msg.scap_references = incoming_data['references'] msg.value = incoming_data['title'] + msg.fixes = fixes(incoming_data['fixes']) return unless msg.changed? msg.save diff --git a/app/views/arf_reports/_output.html.erb b/app/views/arf_reports/_output.html.erb index 431fcc20..b168a6e4 100644 --- a/app/views/arf_reports/_output.html.erb +++ b/app/views/arf_reports/_output.html.erb @@ -25,7 +25,7 @@ <%= log.source %> <%= react_component 'RuleSeverity', { :severity => log.message.severity.downcase } %> - <%= host_search_by_rule_result_buttons(log.source) %> + <%= host_search_by_rule_result_buttons(log) %> <% end %> 0%>> @@ -35,3 +35,7 @@ +<%= react_component 'OpenscapRemediationWizard', + { report_id: @arf_report.id, + host: { name: @arf_report.host.name, id: @arf_report.host.id }, + supported_remediation_snippets: supported_remediation_snippets } %> diff --git a/app/views/job_templates/run_openscap_remediation_-_ansible_default.erb b/app/views/job_templates/run_openscap_remediation_-_ansible_default.erb new file mode 100644 index 00000000..ef8a4236 --- /dev/null +++ b/app/views/job_templates/run_openscap_remediation_-_ansible_default.erb @@ -0,0 +1,27 @@ +<%# +name: Run OpenSCAP remediation - Ansible Default +job_category: OpenSCAP Ansible Commands +description_format: Run OpenSCAP remediation on given host. Please note, it is not meant to be used directly. +snippet: false +provider_type: Ansible +kind: job_template +model: JobTemplate +feature: ansible_run_openscap_remediation +template_inputs: +- name: tasks + description: Tasks to run on the host + input_type: user + required: true +- name: reboot + description: Indicate wether the host should be rebooted after all the remediation + input_type: user + required: false +%> +--- +- hosts: all + tasks: +<%= indent(4) { input('tasks') } -%> +<% if truthy?(input('reboot')) %> + - name: Reboot the machine + reboot: +<% end %> diff --git a/app/views/job_templates/run_openscap_remediation_-_script_default.erb b/app/views/job_templates/run_openscap_remediation_-_script_default.erb new file mode 100644 index 00000000..1112aa89 --- /dev/null +++ b/app/views/job_templates/run_openscap_remediation_-_script_default.erb @@ -0,0 +1,24 @@ +<%# +name: Run OpenSCAP remediation - Script Default +job_category: OpenSCAP +description_format: Run OpenSCAP remediation on given host. Please note, it is not meant to be used directly. +snippet: false +provider_type: script +kind: job_template +model: JobTemplate +feature: script_run_openscap_remediation +template_inputs: +- name: command + description: Command to run on the host + input_type: user + required: true +- name: reboot + description: Indicate wether the host should be rebooted after the remediation + input_type: user + required: false +%> +<%= input('command') %> +<% if truthy?(input('reboot')) -%> +echo "A reboot is required to finish the remediation. The system is going to reboot now." +<%= render_template('Power Action - Script Default', action: 'restart') %> +<% end -%> diff --git a/config/routes.rb b/config/routes.rb index fc9c6756..3ac179be 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,7 @@ get 'parse_html' get 'parse_bzip' get 'download_html' + get 'show_log' end collection do get 'auto_complete_search' diff --git a/db/migrate/20230912122310_add_fixes_to_message.rb b/db/migrate/20230912122310_add_fixes_to_message.rb new file mode 100644 index 00000000..bd412d72 --- /dev/null +++ b/db/migrate/20230912122310_add_fixes_to_message.rb @@ -0,0 +1,5 @@ +class AddFixesToMessage < ActiveRecord::Migration[6.1] + def change + add_column :messages, :fixes, :text + end +end diff --git a/lib/foreman_openscap/engine.rb b/lib/foreman_openscap/engine.rb index 94e37e48..2229c45a 100644 --- a/lib/foreman_openscap/engine.rb +++ b/lib/foreman_openscap/engine.rb @@ -48,7 +48,7 @@ class Engine < ::Rails::Engine initializer 'foreman_openscap.register_plugin', :before => :finisher_hook do |app| Foreman::Plugin.register :foreman_openscap do - requires_foreman '>= 3.7' + requires_foreman '>= 3.11' register_gettext apipie_documented_controllers ["#{ForemanOpenscap::Engine.root}/app/controllers/api/v2/compliance/*.rb"] @@ -58,7 +58,7 @@ class Engine < ::Rails::Engine # Add permissions security_block :foreman_openscap do - permission :view_arf_reports, { :arf_reports => %i[index show parse_html show_html + permission :view_arf_reports, { :arf_reports => %i[index show parse_html show_html show_log parse_bzip auto_complete_search download_html], 'api/v2/compliance/arf_reports' => %i[index show download download_html], :compliance_hosts => [:show] }, @@ -275,6 +275,16 @@ class Engine < ::Rails::Engine :description => N_("Run OVAL scan") } + ansible_remediation_options = { + :description => N_("Run OpenSCAP remediation with Ansible"), + :provided_inputs => ["tasks", "reboot"] + } + + script_remediation_options = { + :description => N_("Run OpenSCAP remediation with Shell"), + :provided_inputs => ["command", "reboot"] + } + if Gem::Version.new(ForemanRemoteExecution::VERSION) >= Gem::Version.new('1.2.3') options[:host_action_button] = true oval_options[:host_action_button] = (!::Foreman.in_rake? && ActiveRecord::Base.connection.table_exists?(:settings)) ? (Setting.find_by(:name => 'lab_features')&.value || false) : false @@ -282,6 +292,8 @@ class Engine < ::Rails::Engine RemoteExecutionFeature.register(:foreman_openscap_run_scans, N_("Run OpenSCAP scan"), options) RemoteExecutionFeature.register(:foreman_openscap_run_oval_scans, N_("Run OVAL scan"), oval_options) + RemoteExecutionFeature.register(:ansible_run_openscap_remediation, N_("Run OpenSCAP remediation with Ansible"), ansible_remediation_options) + RemoteExecutionFeature.register(:script_run_openscap_remediation, N_("Run OpenSCAP remediation with Shell"), script_remediation_options) end end @@ -303,4 +315,8 @@ def self.use_relative_model_naming? def self.with_remote_execution? RemoteExecutionFeature rescue false end + + def self.with_ansible? + ForemanAnsible rescue false + end end diff --git a/webpack/components/EmptyState.js b/webpack/components/EmptyState.js index 105cf48e..fc7fda8b 100644 --- a/webpack/components/EmptyState.js +++ b/webpack/components/EmptyState.js @@ -56,8 +56,12 @@ EmptyStateIcon.defaultProps = { EmptyState.propTypes = { title: PropTypes.string, - body: PropTypes.string, - error: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.string]), + body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + error: PropTypes.oneOfType([ + PropTypes.shape({}), + PropTypes.string, + PropTypes.bool, + ]), search: PropTypes.bool, lock: PropTypes.bool, primaryButton: PropTypes.node, diff --git a/webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js new file mode 100644 index 00000000..8de47a8f --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js @@ -0,0 +1,16 @@ +import { + selectAPIError, + selectAPIResponse, + selectAPIStatus, +} from 'foremanReact/redux/API/APISelectors'; +import { STATUS } from 'foremanReact/constants'; +import { REPORT_LOG_REQUEST_KEY } from './constants'; + +export const selectLogResponse = state => + selectAPIResponse(state, REPORT_LOG_REQUEST_KEY); + +export const selectLogStatus = state => + selectAPIStatus(state, REPORT_LOG_REQUEST_KEY) || STATUS.PENDING; + +export const selectLogError = state => + selectAPIError(state, REPORT_LOG_REQUEST_KEY); diff --git a/webpack/components/OpenscapRemediationWizard/OpenscapRemediationWizardContext.js b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationWizardContext.js new file mode 100644 index 00000000..ec5dd749 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationWizardContext.js @@ -0,0 +1,4 @@ +import { createContext } from 'react'; + +const OpenscapRemediationWizardContext = createContext({}); +export default OpenscapRemediationWizardContext; diff --git a/webpack/components/OpenscapRemediationWizard/ViewSelectedHostsLink.js b/webpack/components/OpenscapRemediationWizard/ViewSelectedHostsLink.js new file mode 100644 index 00000000..31593d2c --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/ViewSelectedHostsLink.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons'; +import { Button } from '@patternfly/react-core'; + +import { translate as __ } from 'foremanReact/common/I18n'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { getHostsPageUrl } from 'foremanReact/Root/Context/ForemanContext'; + +const ViewSelectedHostsLink = ({ + hostIdsParam, + isAllHostsSelected, + defaultFailedHostsSearch, +}) => { + const search = isAllHostsSelected ? defaultFailedHostsSearch : hostIdsParam; + const url = foremanUrl(`${getHostsPageUrl(false)}?search=${search}`); + return ( + + ); +}; + +ViewSelectedHostsLink.propTypes = { + isAllHostsSelected: PropTypes.bool.isRequired, + defaultFailedHostsSearch: PropTypes.string.isRequired, + hostIdsParam: PropTypes.string.isRequired, +}; + +export default ViewSelectedHostsLink; diff --git a/webpack/components/OpenscapRemediationWizard/WizardHeader.js b/webpack/components/OpenscapRemediationWizard/WizardHeader.js new file mode 100644 index 00000000..532989e6 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/WizardHeader.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Grid, + TextContent, + Text, + TextVariants, + Flex, + FlexItem, +} from '@patternfly/react-core'; + +const WizardHeader = ({ title, description }) => ( + + {title && ( + + + {title} + + + )} + {description && ( + + + + {description} + + + + )} + +); + +WizardHeader.propTypes = { + title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + description: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), +}; + +WizardHeader.defaultProps = { + title: undefined, + description: undefined, +}; + +export default WizardHeader; diff --git a/webpack/components/OpenscapRemediationWizard/constants.js b/webpack/components/OpenscapRemediationWizard/constants.js new file mode 100644 index 00000000..7f3e4ddb --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/constants.js @@ -0,0 +1,14 @@ +export const OPENSCAP_REMEDIATION_MODAL_ID = 'openscapRemediationModal'; +export const HOSTS_PATH = '/hosts'; +export const FAIL_RULE_SEARCH = 'fails_xccdf_rule'; + +export const HOSTS_API_PATH = '/api/hosts'; +export const HOSTS_API_REQUEST_KEY = 'HOSTS'; +export const REPORT_LOG_REQUEST_KEY = 'ARF_REPORT_LOG'; + +export const JOB_INVOCATION_PATH = '/job_invocations'; +export const JOB_INVOCATION_API_PATH = '/api/job_invocations'; +export const JOB_INVOCATION_API_REQUEST_KEY = 'OPENSCAP_REX_JOB_INVOCATIONS'; + +export const SNIPPET_SH = 'urn:xccdf:fix:script:sh'; +export const SNIPPET_ANSIBLE = 'urn:xccdf:fix:script:ansible'; diff --git a/webpack/components/OpenscapRemediationWizard/helpers.js b/webpack/components/OpenscapRemediationWizard/helpers.js new file mode 100644 index 00000000..cf127372 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/helpers.js @@ -0,0 +1,33 @@ +import { join, find, map, compact, includes, filter, isString } from 'lodash'; + +const getResponseErrorMsgs = ({ data } = {}) => { + if (data) { + const messages = + data.displayMessage || data.message || data.errors || data.error?.message; + return Array.isArray(messages) ? messages : [messages]; + } + return []; +}; + +export const errorMsg = data => { + if (isString(data)) return data; + + return join(getResponseErrorMsgs({ data }), '\n'); +}; + +export const findFixBySnippet = (fixes, snippet) => + find(fixes, fix => fix.system === snippet); + +export const supportedRemediationSnippets = ( + fixes, + meth, + supportedJobSnippets +) => { + if (meth === 'manual') return map(fixes, f => f.system); + return compact( + map( + filter(fixes, fix => includes(supportedJobSnippets, fix.system)), + f => f.system + ) + ); +}; diff --git a/webpack/components/OpenscapRemediationWizard/index.js b/webpack/components/OpenscapRemediationWizard/index.js new file mode 100644 index 00000000..1db35e9c --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/index.js @@ -0,0 +1,160 @@ +import React, { useState, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Button, Wizard } from '@patternfly/react-core'; + +import { sprintf, translate as __ } from 'foremanReact/common/I18n'; +import { API_OPERATIONS, get } from 'foremanReact/redux/API'; + +import OpenscapRemediationWizardContext from './OpenscapRemediationWizardContext'; +import { + selectLogResponse, + selectLogError, + selectLogStatus, +} from './OpenscapRemediationSelectors'; +import { REPORT_LOG_REQUEST_KEY, FAIL_RULE_SEARCH } from './constants'; +import { SnippetSelect, ReviewHosts, ReviewRemediation, Finish } from './steps'; + +const OpenscapRemediationWizard = ({ + report_id: reportId, + host: { id: hostId, name: hostName }, + supported_remediation_snippets: supportedJobSnippets, +}) => { + const dispatch = useDispatch(); + const log = useSelector(state => selectLogResponse(state))?.log; + const logStatus = useSelector(state => selectLogStatus(state)); + const logError = useSelector(state => selectLogError(state)); + + const fixes = JSON.parse(log?.message?.fixes || null) || []; + const source = log?.source?.value || ''; + const title = log?.message?.value || ''; + const defaultHostIdsParam = `id ^ (${hostId})`; + const defaultFailedHostsSearch = `${FAIL_RULE_SEARCH} = ${source}`; + + const [isRemediationWizardOpen, setIsRemediationWizardOpen] = useState(false); + const [snippet, setSnippet] = useState(''); + const [method, setMethod] = useState('job'); + const [hostIdsParam, setHostIdsParam] = useState(defaultHostIdsParam); + const [isRebootSelected, setIsRebootSelected] = useState(false); + const [isAllHostsSelected, setIsAllHostsSelected] = useState(false); + + const savedHostSelectionsRef = useRef({}); + + const onModalButtonClick = e => { + e.preventDefault(); + const logId = e.target.getAttribute('data-log-id'); + dispatch( + get({ + type: API_OPERATIONS.GET, + key: REPORT_LOG_REQUEST_KEY, + url: `/compliance/arf_reports/${reportId}/show_log`, + params: { log_id: logId }, + }) + ); + setIsRemediationWizardOpen(true); + }; + + const onWizardClose = () => { + // Reset to defaults + setHostIdsParam(defaultHostIdsParam); + setSnippet(''); + setMethod('job'); + setIsRebootSelected(false); + setIsRemediationWizardOpen(false); + savedHostSelectionsRef.current = {}; + }; + + const reviewHostsStep = { + id: 2, + name: __('Review hosts'), + component: , + canJumpTo: Boolean(snippet && method === 'job'), + enableNext: Boolean(snippet && method), + }; + const steps = [ + { + id: 1, + name: __('Select snippet'), + component: , + canJumpTo: true, + enableNext: Boolean(snippet && method), + }, + ...(snippet && method === 'job' ? [reviewHostsStep] : []), + { + id: 3, + name: __('Review remediation'), + component: , + canJumpTo: Boolean(snippet && method), + enableNext: method === 'job', + nextButtonText: __('Run'), + }, + { + id: 4, + name: __('Done'), + component: , + isFinishedStep: true, + }, + ]; + + return ( + <> + {isRemediationWizardOpen && ( + + + + )} + + ); + const closeBtn = ; + const errorComponent = + statusCode === 403 ? ( + + ) : ( + + ); + const body = + status === STATUS.RESOLVED ? ( + + {linkToJob} + + + } + primaryButton={closeBtn} + /> + ) : ( + errorComponent + ); + + return {status === STATUS.PENDING ? : body}; +}; + +Finish.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +export default Finish; diff --git a/webpack/components/OpenscapRemediationWizard/steps/ReviewHosts.js b/webpack/components/OpenscapRemediationWizard/steps/ReviewHosts.js new file mode 100644 index 00000000..1d37a3a3 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/steps/ReviewHosts.js @@ -0,0 +1,217 @@ +import React, { useContext, useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Spinner, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; +import { Td } from '@patternfly/react-table'; +import { toArray } from 'lodash'; + +import { foremanUrl } from 'foremanReact/common/helpers'; +import { translate as __ } from 'foremanReact/common/I18n'; +import SelectAllCheckbox from 'foremanReact/components/PF4/TableIndexPage/Table/SelectAllCheckbox'; +import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table'; +import { useBulkSelect } from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks'; +import { getPageStats } from 'foremanReact/components/PF4/TableIndexPage/Table/helpers'; +import { STATUS } from 'foremanReact/constants'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; + +import OpenscapRemediationWizardContext from '../OpenscapRemediationWizardContext'; +import WizardHeader from '../WizardHeader'; +import { HOSTS_API_PATH, HOSTS_API_REQUEST_KEY } from '../constants'; + +const ReviewHosts = () => { + const { + hostId, + setHostIdsParam, + defaultFailedHostsSearch, + setIsAllHostsSelected, + savedHostSelectionsRef, + } = useContext(OpenscapRemediationWizardContext); + + const defaultParams = { + search: defaultFailedHostsSearch, + }; + const defaultHostsArry = [hostId]; + + const [params, setParams] = useState(defaultParams); + + const response = useAPI('get', `${HOSTS_API_PATH}?include_permissions=true`, { + key: HOSTS_API_REQUEST_KEY, + params: defaultParams, + }); + const { + response: { + search: apiSearchQuery, + results, + per_page: perPage, + page, + subtotal, + message: errorMessage, + }, + status = STATUS.PENDING, + setAPIOptions, + } = response; + + const subtotalCount = Number(subtotal ?? 0); + + const setParamsAndAPI = newParams => { + setParams(newParams); + setAPIOptions({ key: HOSTS_API_REQUEST_KEY, params: newParams }); + }; + + const { pageRowCount } = getPageStats({ + total: subtotalCount, + page, + perPage, + }); + const { fetchBulkParams, ...selectAllOptions } = useBulkSelect({ + results, + metadata: { total: subtotalCount, page }, + initialSearchQuery: apiSearchQuery || defaultFailedHostsSearch, + isSelectable: () => true, + defaultArry: defaultHostsArry, + initialArry: toArray( + savedHostSelectionsRef.current.inclusionSet || defaultHostsArry + ), + initialExclusionArry: toArray( + savedHostSelectionsRef.current.exclusionSet || [] + ), + initialSelectAllMode: savedHostSelectionsRef.current.selectAllMode || false, + }); + const { + selectPage, + selectedCount, + selectOne, + selectNone, + selectDefault, + selectAll, + areAllRowsOnPageSelected, + areAllRowsSelected, + isSelected, + inclusionSet, + exclusionSet, + selectAllMode, + } = selectAllOptions; + + useEffect(() => { + if (selectedCount) { + setHostIdsParam(fetchBulkParams()); + savedHostSelectionsRef.current = { + inclusionSet, + exclusionSet, + selectAllMode, + }; + } + }, [selectedCount, fetchBulkParams, setHostIdsParam]); + + const selectionToolbar = ( + + { + selectAll(true); + setIsAllHostsSelected(true); + }, + selectPage: () => { + selectPage(); + setIsAllHostsSelected(false); + }, + selectDefault: () => { + selectDefault(); + setIsAllHostsSelected(false); + }, + selectNone: () => { + selectNone(); + setIsAllHostsSelected(false); + }, + selectedCount, + pageRowCount, + }} + totalCount={subtotalCount} + selectedDefaultCount={1} // The default host (hostId) is always selected + areAllRowsOnPageSelected={areAllRowsOnPageSelected()} + areAllRowsSelected={areAllRowsSelected()} + /> + + ); + + const RowSelectTd = ({ rowData }) => ( + { + selectOne(isSelecting, rowData.id, rowData); + // If at least one was unselected, then it's not all selected + if (!isSelecting) setIsAllHostsSelected(false); + }, + isSelected: rowData.id === hostId || isSelected(rowData.id), + disable: rowData.id === hostId || false, + }} + /> + ); + RowSelectTd.propTypes = { + rowData: PropTypes.object.isRequired, + }; + + const columns = { + name: { + title: __('Name'), + wrapper: ({ id, name }) => {name}, + isSorted: true, + }, + operatingsystem_name: { + title: __('OS'), + }, + }; + + return ( + <> + + + + + {selectionToolbar} + {status === STATUS.PENDING && ( + + + + )} + + + + + setAPIOptions({ + key: HOSTS_API_REQUEST_KEY, + params: { defaultFailedHostsSearch }, + }) + } + columns={columns} + errorMessage={ + status === STATUS.ERROR && errorMessage ? errorMessage : null + } + isPending={status === STATUS.PENDING} + showCheckboxes + rowSelectTd={RowSelectTd} + /> + + ); +}; + +export default ReviewHosts; diff --git a/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js b/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js new file mode 100644 index 00000000..afbda9d3 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js @@ -0,0 +1,176 @@ +/* eslint-disable camelcase */ +import React, { useContext, useState } from 'react'; +import { some } from 'lodash'; +import { + CodeBlock, + CodeBlockAction, + CodeBlockCode, + ClipboardCopyButton, + Button, + Grid, + GridItem, + Alert, + Checkbox, +} from '@patternfly/react-core'; +import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons'; + +import { translate as __ } from 'foremanReact/common/I18n'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { getHostsPageUrl } from 'foremanReact/Root/Context/ForemanContext'; + +import OpenscapRemediationWizardContext from '../OpenscapRemediationWizardContext'; +import WizardHeader from '../WizardHeader'; +import ViewSelectedHostsLink from '../ViewSelectedHostsLink'; +import { HOSTS_PATH, FAIL_RULE_SEARCH } from '../constants'; +import { findFixBySnippet } from '../helpers'; + +import './ReviewRemediation.scss'; + +const ReviewRemediation = () => { + const { + fixes, + snippet, + method, + hostName, + source, + isRebootSelected, + setIsRebootSelected, + isAllHostsSelected, + hostIdsParam, + defaultFailedHostsSearch, + } = useContext(OpenscapRemediationWizardContext); + const [copied, setCopied] = useState(false); + const selectedFix = findFixBySnippet(fixes, snippet); + const snippetText = selectedFix?.full_text; + // can be one of null, "true", "false" + // if null, it may indicate that reboot might be needed + const checkForReboot = () => !some(fixes, { reboot: 'false' }); + const isRebootRequired = () => some(fixes, { reboot: 'true' }); + + const copyToClipboard = (e, text) => { + navigator.clipboard.writeText(text.toString()); + }; + + const onCopyClick = (e, text) => { + copyToClipboard(e, text); + setCopied(true); + }; + + const description = + method === 'manual' + ? __('Review the remediation snippet and apply it to the host manually.') + : __( + 'Review the remediation snippet that will be applied to selected host(s).' + ); + + const rebootAlertTitle = isRebootRequired() + ? __('A reboot is required after applying remediation.') + : __('A reboot might be required after applying remediation.'); + + const actions = ( + + + onCopyClick(e, snippetText)} + exitDelay={copied ? 1500 : 600} + maxWidth="110px" + variant="plain" + onTooltipHidden={() => setCopied(false)} + > + {copied + ? __('Successfully copied to clipboard!') + : __('Copy to clipboard')} + + + + ); + + return ( + <> + + +
+ + + + + + + + {' '} + + + + + {checkForReboot() ? ( + <> + + + + {method === 'manual' ? null : ( + + setIsRebootSelected(selected)} + /> + + )} + + ) : null} + + + + {snippetText} + + + +
+ + ); +}; + +export default ReviewRemediation; diff --git a/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.scss b/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.scss new file mode 100644 index 00000000..6f43dab4 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.scss @@ -0,0 +1,4 @@ +pre.remediation-code { + border: none; + border-radius: none; +} diff --git a/webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js b/webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js new file mode 100644 index 00000000..0e040a9b --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js @@ -0,0 +1,160 @@ +import React, { useContext } from 'react'; +import { map, split, capitalize, join, slice, isEmpty } from 'lodash'; +import { + Form, + FormGroup, + FormSelect, + FormSelectOption, + Radio, + Alert, +} from '@patternfly/react-core'; + +import { translate as __ } from 'foremanReact/common/I18n'; +import { STATUS } from 'foremanReact/constants'; +import Loading from 'foremanReact/components/Loading'; + +import OpenscapRemediationWizardContext from '../OpenscapRemediationWizardContext'; +import WizardHeader from '../WizardHeader'; +import EmptyState from '../../EmptyState'; +import { errorMsg, supportedRemediationSnippets } from '../helpers'; + +const SnippetSelect = () => { + const { + fixes, + snippet, + setSnippet, + method, + setMethod, + logStatus, + logError, + supportedJobSnippets, + } = useContext(OpenscapRemediationWizardContext); + + const snippetNameMap = { + 'urn:xccdf:fix:script:ansible': 'Ansible', + 'urn:xccdf:fix:script:puppet': 'Puppet', + 'urn:xccdf:fix:script:sh': 'Shell', + 'urn:xccdf:fix:script:kubernetes': 'Kubernetes', + 'urn:redhat:anaconda:pre': 'Anaconda', + 'urn:redhat:osbuild:blueprint': 'OSBuild Blueprint', + }; + + const snippetName = system => { + const mapped = snippetNameMap[system]; + if (mapped) return mapped; + + return join( + map(slice(split(system, ':'), -2), n => capitalize(n)), + ' ' + ); + }; + + const resetSnippet = meth => { + const snip = supportedRemediationSnippets( + fixes, + meth, + supportedJobSnippets + )[0]; + setSnippet(snip); + return snip; + }; + + const setMethodResetSnippet = meth => { + setMethod(meth); + resetSnippet(meth); + }; + + const body = + logStatus === STATUS.RESOLVED ? ( +
+ + setMethodResetSnippet('job')} + /> + setMethodResetSnippet('manual')} + /> + + {isEmpty( + supportedRemediationSnippets(fixes, method, supportedJobSnippets) + ) ? ( + + ) : ( + + setSnippet(value)} + aria-label="FormSelect Input" + > + + {map( + supportedRemediationSnippets( + fixes, + method, + supportedJobSnippets + ), + fix => ( + + ) + )} + + + )} + + ) : ( + + ); + + return ( + <> + + {logStatus === STATUS.PENDING ? : body} + + ); +}; + +export default SnippetSelect; diff --git a/webpack/components/OpenscapRemediationWizard/steps/index.js b/webpack/components/OpenscapRemediationWizard/steps/index.js new file mode 100644 index 00000000..dcef2667 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/steps/index.js @@ -0,0 +1,4 @@ +export { default as SnippetSelect } from './SnippetSelect'; +export { default as ReviewHosts } from './ReviewHosts'; +export { default as ReviewRemediation } from './ReviewRemediation'; +export { default as Finish } from './Finish'; diff --git a/webpack/index.js b/webpack/index.js index 5d666c39..24ddd207 100644 --- a/webpack/index.js +++ b/webpack/index.js @@ -1,8 +1,13 @@ import componentRegistry from 'foremanReact/components/componentRegistry'; import RuleSeverity from './components/RuleSeverity'; +import OpenscapRemediationWizard from './components/OpenscapRemediationWizard'; -componentRegistry.register({ - name: 'RuleSeverity', - type: RuleSeverity, +const components = [ + { name: 'RuleSeverity', type: RuleSeverity }, + { name: 'OpenscapRemediationWizard', type: OpenscapRemediationWizard }, +]; + +components.forEach(component => { + componentRegistry.register(component); });