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 = {__('Close')};
+ 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 (
+ <>
+
+
+
+ }
+ iconPosition="right"
+ target="_blank"
+ component="a"
+ href={foremanUrl(`${NEW_HOSTS_PATH}/${hostName}`)}
+ >
+ {hostName}
+ {' '}
+
+
+ }
+ iconPosition="right"
+ target="_blank"
+ component="a"
+ href={foremanUrl(
+ `${HOSTS_PATH}/?search=${FAIL_RULE_SEARCH} = ${source}`
+ )}
+ >
+ {__('Other hosts failing this rule')}
+
+
+
+
+
+ {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 ? (
+
+ ) : (
+
+ );
+
+ 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);
});