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..70179d62 100644 --- a/app/helpers/arf_reports_helper.rb +++ b/app/helpers/arf_reports_helper.rb @@ -62,9 +62,17 @@ 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) + action_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}")), + link_to_function_if_authorized(_('Remediation'), "showRemediationWizard(#{log.id})", hash_for_show_log_arf_report_path(id: log.report.id))) + 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..0c2180a8 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 => JSON.fast_generate(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 => JSON.fast_generate(log[:fixes])) end msg.save! end @@ -228,6 +230,7 @@ def self.update_msg_with_changes(msg, incoming_data) msg.rationale = incoming_data['rationale'] msg.scap_references = incoming_data['references'] msg.value = incoming_data['title'] + msg.fixes = JSON.fast_generate(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..878a990c --- /dev/null +++ b/app/views/job_templates/run_openscap_remediation_-_ansible_default.erb @@ -0,0 +1,19 @@ +<%# +name: Run OpenSCAP remediation - Ansible Default +job_category: OpenSCAP Ansible Commands +description_format: Run OpenSCAP remediation on given host +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 +%> +--- +- hosts: all + tasks: + <%= input('tasks') %> 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..531be545 100644 --- a/lib/foreman_openscap/engine.rb +++ b/lib/foreman_openscap/engine.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,11 @@ class Engine < ::Rails::Engine :description => N_("Run OVAL scan") } + ansible_remediation_options = { + :description => N_("Run OpenSCAP remediation with Ansible"), + :provided_inputs => "tasks" + } + 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 +287,7 @@ 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) end end @@ -303,4 +309,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..a971cf29 100644 --- a/webpack/components/EmptyState.js +++ b/webpack/components/EmptyState.js @@ -56,7 +56,7 @@ EmptyStateIcon.defaultProps = { EmptyState.propTypes = { title: PropTypes.string, - body: PropTypes.string, + body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), error: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.string]), search: PropTypes.bool, lock: PropTypes.bool, diff --git a/webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js new file mode 100644 index 00000000..8c20f339 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js @@ -0,0 +1,28 @@ +import { + selectAPIError, + selectAPIResponse, + selectAPIStatus, +} from 'foremanReact/redux/API/APISelectors'; +import { STATUS } from 'foremanReact/constants'; +import { + JOB_INVOCATION_API_REQUEST_KEY, + REPORT_LOG_REQUEST_KEY, +} from './constants'; + +export const selectRemediationResponse = state => + selectAPIResponse(state, JOB_INVOCATION_API_REQUEST_KEY) || {}; + +export const selectRemediationStatus = state => + selectAPIStatus(state, JOB_INVOCATION_API_REQUEST_KEY) || STATUS.PENDING; + +export const selectRemediationError = state => + selectAPIError(state, JOB_INVOCATION_API_REQUEST_KEY); + +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/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..637778ea --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/constants.js @@ -0,0 +1,15 @@ +export const OPENSCAP_REMEDIATION_MODAL_ID = 'openscapRemediationModal'; +export const NEW_HOSTS_PATH = '/new/hosts'; +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..16a78d30 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/helpers.js @@ -0,0 +1,14 @@ +import { join } 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 = error => { + join(getResponseErrorMsgs(error?.response || {}), '\n'); +}; diff --git a/webpack/components/OpenscapRemediationWizard/index.js b/webpack/components/OpenscapRemediationWizard/index.js new file mode 100644 index 00000000..12cac313 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/index.js @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { isEmpty } from 'lodash'; +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 } 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 [isRemediationWizardOpen, setIsRemediationWizardOpen] = useState(false); + const [snippet, setSnippet] = useState(''); + const [method, setMethod] = useState('job'); + const [hostIds, setHostIds] = useState([hostId]); + + 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 = () => { + setHostIds([hostId]); + setIsRemediationWizardOpen(false); + }; + + const reviewHostsStep = { + id: 2, + name: __('Review hosts'), + component: , + canJumpTo: snippet && method === 'job', + enableNext: !isEmpty(hostIds), + }; + const steps = [ + { + id: 1, + name: __('Select snippet'), + component: , + canJumpTo: true, + enableNext: snippet && method, + }, + ...(snippet && method === 'job' ? [reviewHostsStep] : []), + { + id: 3, + name: __('Review remediation'), + component: , + canJumpTo: snippet && method && !isEmpty(hostIds), + enableNext: method === 'job', + nextButtonText: __('Run'), + }, + { + id: 4, + name: __('Done'), + component: , + isFinishedStep: true, + }, + ]; + + return ( + <> + {isRemediationWizardOpen && ( + + + + )} + + ); + const closeBtn = ; + const body = + status === STATUS.RESOLVED ? ( + + ) : ( + + ); + + 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..dc9d4653 --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/steps/ReviewHosts.js @@ -0,0 +1,267 @@ +import React, { useContext, useState, useEffect, useCallback } from 'react'; +import { map, includes, without, union } from 'lodash'; +import { + Spinner, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + Dropdown, + DropdownToggle, + DropdownToggleCheckbox, + DropdownItem, + Checkbox, +} from '@patternfly/react-core'; + +import { sprintf, translate as __ } from 'foremanReact/common/I18n'; +import SearchBar from 'foremanReact/components/SearchBar'; +import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table'; +import { getControllerSearchProps, 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, + FAIL_RULE_SEARCH, +} from '../constants'; + +const ReviewHosts = () => { + const { source, setHostIds, hostIds } = useContext( + OpenscapRemediationWizardContext + ); + + const defaultSearch = `${FAIL_RULE_SEARCH} = ${source}`; + const defaultParams = { + // TODO: for some reason pagination is visually broken + search: defaultSearch, + page: 1, + per_page: 20, + }; + + const [params, setParams] = useState(defaultParams); + const [isSelectDropdownOpen, setSelectDropdownOpen] = useState(false); + const [selectionToggle, setSelectionToggle] = useState(false); + + const searchProps = getControllerSearchProps('hosts'); + searchProps.autocomplete.searchQuery = defaultSearch; + searchProps.autocomplete.url = '../../hosts/auto_complete_search'; // TODO: find a way to not use relative path + + const { + response: { + search: apiSearchQuery, + results, + subtotal, + message: errorMessage, + }, + status = STATUS.PENDING, + setAPIOptions, + } = useAPI('get', `${HOSTS_API_PATH}?include_permissions=true`, { + // TODO: verify permissions + key: HOSTS_API_REQUEST_KEY, + params, + }); + + const subtotalCount = Number(subtotal ?? 0); + + const setParamsAndAPI = newParams => { + setParams(newParams); + setAPIOptions({ key: HOSTS_API_REQUEST_KEY, params: newParams }); + }; + + const onSearch = newSearch => { + if (newSearch !== apiSearchQuery) { + setParamsAndAPI({ ...params, search: newSearch, page: 1 }); + } + }; + + const onSelectCheckboxChange = checked => { + if (checked && selectionToggle !== null) { + handleSelectPage(); + } else { + handleSelectNone(); + } + }; + + const isHostSelected = id => includes(hostIds, id); + const selectHost = (id, selected) => { + if (selected) { + if (!isHostSelected(id)) { + setHostIds(union(hostIds, [id])); + } + } else if (isHostSelected(id)) { + setHostIds([...without(hostIds, id)]); + } + }; + const addHosts = ids => { + setHostIds(union(hostIds, ids)); + }; + + const handleSelectPage = () => { + setSelectDropdownOpen(false); + setSelectionToggle(true); + addHosts(map(results, h => h.id)); + }; + + const handleSelectNone = () => { + setSelectDropdownOpen(false); + setSelectionToggle(false); + setHostIds([]); + }; + + const areAllRowsSelected = useCallback( + () => subtotalCount === hostIds.length, + [subtotalCount, hostIds] + ); + + useEffect(() => { + let newCheckedState = null; // null is partially-checked state + + if (areAllRowsSelected()) { + newCheckedState = true; + } else if (hostIds.length === 0) { + newCheckedState = false; + } + setSelectionToggle(newCheckedState); + }, [hostIds, areAllRowsSelected]); + + const getPageRowCount = (total, page, perPage) => { + // logic adapted from patternfly so that we can know the number of items per page + const lastPage = Math.ceil(total / perPage) ?? 0; + const firstIndex = total <= 0 ? 0 : (page - 1) * perPage + 1; + let lastIndex; + if (total <= 0) { + lastIndex = 0; + } else { + lastIndex = page === lastPage ? total : page * perPage; + } + let pageRowCount = lastIndex - firstIndex + 1; + if (total <= 0) pageRowCount = 0; + return pageRowCount; + }; + + const selectDropdownItems = [ + + {`${__('Select none')} (0)`} + , + + {`${__('Select page')} (${getPageRowCount( + subtotal, + params.page, + params.per_page + )})`} + , + ]; + + const columns = { + selected: { + title: '', + wrapper: ({ id }) => ( + selectHost(id, selected)} + /> + ), + }, + name: { + title: __('Name'), + isSorted: true, + }, + operatingsystem_name: { + title: __('OS'), + }, + }; + + return ( + <> + + + + + + setSelectDropdownOpen(isOpen => !isOpen)} + id="select-all-checkbox-dropdown-toggle" + ouiaId="select-all-checkbox-dropdown-toggle" + splitButtonItems={[ + onSelectCheckboxChange(checked)} + isChecked={selectionToggle} + isDisabled={subtotalCount === 0 && hostIds.length === 0} + > + {hostIds.length > 0 && + sprintf(__('%s selected'), hostIds.length)} + , + ]} + /> + } + isOpen={isSelectDropdownOpen} + dropdownItems={selectDropdownItems} + id="selection-checkbox" + ouiaId="selection-checkbox" + /> + + + + + {status === STATUS.PENDING && ( + + + + )} + + + + + setAPIOptions({ + key: HOSTS_API_REQUEST_KEY, + params: { defaultSearch }, + }) + } + columns={columns} + errorMessage={ + status === STATUS.ERROR && errorMessage ? errorMessage : null + } + isPending={status === STATUS.PENDING} + /> + + ); +}; + +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..fd51b40b --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js @@ -0,0 +1,118 @@ +/* eslint-disable camelcase */ +import React, { useContext, useState } from 'react'; +import { find } from 'lodash'; +import { + CodeBlock, + CodeBlockAction, + CodeBlockCode, + ClipboardCopyButton, + Button, + Grid, + GridItem, +} from '@patternfly/react-core'; +import ExternalLinkSquareAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-square-alt-icon'; + +import { sprintf, translate as __ } from 'foremanReact/common/I18n'; +import { foremanUrl } from 'foremanReact/common/helpers'; + +import OpenscapRemediationWizardContext from '../OpenscapRemediationWizardContext'; +import WizardHeader from '../WizardHeader'; +import { NEW_HOSTS_PATH, HOSTS_PATH, FAIL_RULE_SEARCH } from '../constants'; + +import './ReviewRemediation.scss'; + +const ReviewRemediation = () => { + const { fixes, snippet, method, hostName, source, hostIds } = useContext( + OpenscapRemediationWizardContext + ); + const [copied, setCopied] = useState(false); + const snippetText = find(fixes, fix => fix.system === snippet)?.full_text; + + const copyToClipboard = (e, text) => { + navigator.clipboard.writeText(text.toString()); + }; + + const onCopyClick = (e, text) => { + copyToClipboard(e, text); + setCopied(true); + }; + + const description = + method === 'manual' + ? __( + 'Please review the remediation snippet and apply to the host manually.' + ) + : sprintf( + __( + 'Please review the remediation snippet that will be applied to %s host(s).' + ), + hostIds.length + ); + + 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 ( + <> + + + + {' '} + + + + + + + + {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..cb59adec --- /dev/null +++ b/webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js @@ -0,0 +1,162 @@ +import React, { useContext } from 'react'; +import { + map, + split, + capitalize, + includes, + filter, + 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 } 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(meth)[0]; + setSnippet(snip); + return snip; + }; + + const setMethodResetSnippet = meth => { + setMethod(meth); + resetSnippet(meth); + }; + + const supportedRemediationSnippets = meth => { + if (meth === 'manual') return map(fixes, f => f.system); + return map( + filter(fixes, fix => includes(supportedJobSnippets, fix.system)), + f => f.system + ); + }; + + const body = + logStatus === STATUS.RESOLVED ? ( +
+ + setMethodResetSnippet('job')} + /> + setMethodResetSnippet('manual')} + /> + + {isEmpty(supportedRemediationSnippets(method)) ? ( + + ) : ( + + setSnippet(value)} + aria-label="FormSelect Input" + > + {map(supportedRemediationSnippets(method), 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); });