LCOV - code coverage report
Current view: top level - pkg/selinux - setroubleshoot-view.jsx Coverage Total Hit
Test: cockpit Lines: 85.5 % 346 296
Test Date: 2026-08-04 16:34:20

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2016 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6            3 : import cockpit from "cockpit";
       7              : 
       8            3 : import React from "react";
       9              : import { Alert, AlertActionCloseButton, AlertGroup } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
      10              : import { Badge } from "@patternfly/react-core/dist/esm/components/Badge/index.js";
      11              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      12              : import { Divider } from "@patternfly/react-core/dist/esm/components/Divider/index.js";
      13              : import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
      14              : import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
      15              : import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
      16              : import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
      17              : import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
      18              : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
      19              : import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
      20              : import { ExclamationCircleIcon, ExclamationTriangleIcon, InfoCircleIcon } from "@patternfly/react-icons";
      21              : import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
      22              : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
      23              : import { CodeBlock, CodeBlockCode } from "@patternfly/react-core/dist/esm/components/CodeBlock/index.js";
      24              : 
      25              : import { Modifications } from "cockpit-components-modifications";
      26              : import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
      27              : import { ListingTable } from "cockpit-components-table.jsx";
      28              : import { ListingPanel } from 'cockpit-components-listing-panel.jsx';
      29              : import * as timeformat from 'timeformat';
      30              : import { Title, TitleSizes } from "@patternfly/react-core/dist/esm/components/Title";
      31              : 
      32            3 : const _ = cockpit.gettext;
      33              : 
      34              : /* Show details for an alert, including possible solutions
      35              :  * Props correspond to an item in the setroubleshoot dataStore
      36              :  */
      37            3 : class SELinuxEventDetails extends React.Component {
      38            0 :     runFix(itmIdx, runCommand) {
      39            0 :         const localId = this.props.details.localId;
      40            0 :         const analysisId = this.props.details.pluginAnalysis[itmIdx].analysisId;
      41            0 :         this.props.runFix(localId, analysisId, itmIdx, runCommand);
      42            0 :     }
      43              : 
      44            2 :     render() {
      45            1 :         if (!this.props.details) {
      46              :             // details should be requested by default, so we just need to wait for them
      47            1 :             if (this.props.details === undefined)
      48            1 :                 return <EmptyStatePanel loading title={ _("Waiting for details...") } />;
      49              :             else
      50            1 :                 return <EmptyStatePanel icon={ExclamationCircleIcon} title={ _("Unable to get alert details.") } />;
      51            1 :         }
      52              : 
      53            2 :         const self = this;
      54            2 :         const fixEntries = this.props.details.pluginAnalysis.map(function(itm, itmIdx) {
      55            2 :             let fixit = null;
      56            2 :             let fixit_command = null;
      57            2 :             let msg = null;
      58              : 
      59              :             /* some plugins like catchall_sebool don't report fixable as they offer multiple solutions;
      60              :              * we can offer to run a single setsebool command for convenience */
      61            2 :             let fixable = itm.fixable;
      62            2 :             if (!fixable && itm.doText && itm.doText.startsWith("setsebool") && itm.doText.indexOf("\n") < 0) {
      63            2 :                 fixable = true;
      64            2 :                 fixit_command = itm.doText;
      65            2 :             }
      66              : 
      67            2 :             if (fixable) {
      68            1 :                 if ((itm.fix) && (itm.fix.plugin == itm.analysisId)) {
      69            1 :                     if (itm.fix.running) {
      70            1 :                         msg = (
      71            1 :                             <div>
      72            1 :                                 <Spinner size="sm" className="setroubleshoot-progress-spinner" />
      73            1 :                                 <span className="setroubleshoot-progress-message"> { _("Applying solution...") }</span>
      74            1 :                             </div>
      75              :                         );
      76            1 :                     } else {
      77            1 :                         if (itm.fix.success) {
      78            1 :                             msg = (
      79            1 :                                 <Alert isInline variant="success" title={ _("Solution applied successfully") }>
      80            1 :                                     {itm.fix.result}
      81            1 :                                 </Alert>
      82              :                             );
      83            1 :                         } else {
      84            1 :                             msg = (
      85            1 :                                 <Alert isInline variant="danger" title={ _("Solution failed") }>
      86            1 :                                     {itm.fix.result}
      87            1 :                                 </Alert>
      88              :                             );
      89            1 :                         }
      90            1 :                     }
      91            1 :                 }
      92            2 :                 if (!itm.fix) {
      93            2 :                     fixit = (
      94            2 :                         <div className="setroubleshoot-listing-action">
      95            2 :                             <Button variant="secondary" onClick={ self.runFix.bind(self, itmIdx, fixit_command) }>
      96            2 :                                 { _("Apply this solution") }
      97            2 :                             </Button>
      98            2 :                         </div>
      99              :                     );
     100            2 :                 }
     101            2 :             } else {
     102            2 :                 fixit = (
     103            2 :                     <div className="setroubleshoot-listing-action">
     104            2 :                         <span>{ _("Unable to apply this solution automatically") }</span>
     105            2 :                     </div>
     106              :                 );
     107            2 :             }
     108              : 
     109            2 :             function codeBlock(text, key) {
     110            2 :                 return (
     111            2 :                     <CodeBlock key={key} aria-label={_("solution")}>
     112            2 :                         <CodeBlockCode>{text}</CodeBlockCode>
     113            2 :                     </CodeBlock>
     114              :                 );
     115            2 :             }
     116              : 
     117            2 :             let doElement = "";
     118              : 
     119              :             // One line usually means one command
     120            2 :             if (itm.doText && itm.doText.indexOf("\n") < 0)
     121            2 :                 doElement = codeBlock(itm.doText);
     122              : 
     123              :             // There can be text with commands. Command always starts on a new line with '#'
     124              :             // Group subsequent commands into one `<CodeBlock>` element.
     125            2 :             if (itm.doText && itm.doText.indexOf("\n") >= 0) {
     126            2 :                 const parts = [];
     127            2 :                 const lines = itm.doText.split("\n");
     128            2 :                 let lastCommand = false;
     129            2 :                 lines.forEach(l => {
     130            2 :                     if (l[0] == "#") { // command
     131            2 :                         if (lastCommand) // When appending command remove "# ". Only the first command keeps it and it is removed later on
     132            2 :                             parts[parts.length - 1] += ("\n" + l.substring(2));
     133              :                         else
     134            2 :                             parts.push(l);
     135            2 :                         lastCommand = true;
     136            2 :                     } else {
     137            2 :                         parts.push(l);
     138            2 :                         lastCommand = false;
     139            2 :                     }
     140            2 :                 });
     141            2 :                 doElement = parts.map((p, index) => p[0] == "#"
     142            2 :                     ? codeBlock(p.substring(2), index)
     143            2 :                     : <span key={p}>{p}</span>);
     144            2 :             }
     145              : 
     146            2 :             return (
     147            1 :                 <StackItem key={itm.analysisId + (itm.ifText || "") + (itm.doText || "")}>
     148            2 :                     <div className="selinux-details" data-solution-id={itmIdx}>
     149            2 :                         <div>
     150            2 :                             <div>
     151            2 :                                 <span>{itm.ifText}</span>
     152            2 :                             </div>
     153            2 :                             <div>
     154            2 :                                 {itm.thenText}
     155            2 :                             </div>
     156            2 :                             <ExpandableSection toggleText={_("solution details")}>
     157            2 :                                 {doElement}
     158            2 :                             </ExpandableSection>
     159            2 :                             {msg}
     160            2 :                         </div>
     161            2 :                         {fixit}
     162            2 :                     </div>
     163            2 :                     {itmIdx != self.props.details.pluginAnalysis.length - 1 && <Divider />}
     164            2 :                 </StackItem>
     165              :             );
     166            2 :         });
     167            2 :         return <Stack hasGutter>{fixEntries}</Stack>;
     168            2 :     }
     169            3 : }
     170              : 
     171              : /* Show the audit log events for an alert */
     172            0 : const SELinuxEventLog = ({ details }) => {
     173            0 :     if (!details) {
     174              :         // details should be requested by default, so we just need to wait for them
     175            0 :         if (details === undefined)
     176            0 :             return <EmptyStatePanel loading title={ _("Waiting for details...") } />;
     177              :         else
     178            0 :             return <EmptyStatePanel icon={ExclamationCircleIcon} title={ _("Unable to get alert details.") } />;
     179            0 :     }
     180              : 
     181            0 :     const logEntries = details.auditEvent.map((itm, idx) => {
     182              :         // use the alert id and index in the event log array as the data key for react
     183              :         // if the log becomes dynamic, the entire log line might need to be considered as the key
     184            0 :         return <div key={ details.localId + "." + idx }>{itm}</div>;
     185            0 :     });
     186            0 :     return <div className="setroubleshoot-log">{logEntries}</div>;
     187            0 : };
     188              : 
     189              : /* Component to show a dismissable error, message as child text
     190              :  * dismissError callback function triggered when the close button is pressed
     191              :  */
     192            3 : class DismissableError extends React.Component {
     193            0 :     constructor(props) {
     194            0 :         super(props);
     195            0 :         this.handleDismissError = this.handleDismissError.bind(this);
     196            0 :     }
     197              : 
     198            0 :     handleDismissError(e) {
     199              :         // only consider primary mouse button
     200            0 :         if (!e || e.button !== 0)
     201            0 :             return;
     202            0 :         if (this.props.dismissError)
     203            0 :             this.props.dismissError();
     204            0 :         e.stopPropagation();
     205            0 :     }
     206              : 
     207            0 :     render() {
     208            0 :         return (
     209            0 :             <Alert isInline
     210            0 :                 variant='danger' title={this.props.children}
     211            0 :                 actionClose={<AlertActionCloseButton onClose={this.handleDismissError} />} />
     212              :         );
     213            0 :     }
     214            3 : }
     215              : 
     216              : /* Component to show selinux status and offer an option to change it
     217              :  * selinuxStatus      status of selinux on the system, properties as defined in selinux-client.js
     218              :  * selinuxStatusError error message from reading or setting selinux status/mode
     219              :  * changeSelinuxMode  function to use for changing the selinux enforcing mode
     220              :  * dismissError       function to dismiss the error message
     221              :  */
     222            3 : class SELinuxStatus extends React.Component {
     223            3 :     render() {
     224            3 :         const errorMessage = this.props.selinuxStatusError
     225            1 :             ? <DismissableError dismissError={this.props.dismissError}>{this.props.selinuxStatusError}</DismissableError>
     226            3 :             : null;
     227              : 
     228            3 :         if (this.props.selinuxStatus.enabled === undefined) {
     229              :             // we don't know the current state
     230            3 :             return (
     231            3 :                 <div>
     232            3 :                     {errorMessage}
     233            3 :                     <h3>{_("SELinux system status is unknown.")}</h3>
     234            3 :                 </div>
     235              :             );
     236            1 :         } else if (!this.props.selinuxStatus.enabled) {
     237              :             // selinux is disabled on the system, not much we can do
     238            1 :             return (
     239            1 :                 <div>
     240            1 :                     {errorMessage}
     241            1 :                     <h3>{_("SELinux is disabled on the system.")}</h3>
     242            1 :                 </div>
     243              :             );
     244            1 :         }
     245            3 :         const configUnknown = (this.props.selinuxStatus.configEnforcing === undefined);
     246            3 :         let note = null;
     247            3 :         if (configUnknown)
     248            1 :             note = _("The configured state is unknown, it might change on the next boot.");
     249            3 :         else if (!configUnknown && this.props.selinuxStatus.enforcing !== this.props.selinuxStatus.configEnforcing)
     250            2 :             note = _("Setting deviates from the configured state and will revert on the next boot.");
     251              : 
     252              :         // note = _("Setting deviates from the configured state and will revert on the next boot.");
     253              : 
     254            3 :         return (
     255            3 :             <Stack hasGutter className="selinux-policy-ct">
     256            3 :                 <Flex spaceItems={{ default: 'spaceItemsMd' }} alignItems={{ default: 'alignItemsCenter' }}>
     257            3 :                     <Title headingLevel="h2" size={TitleSizes['3xl']}>
     258            3 :                         {_("SELinux policy")}
     259            3 :                     </Title>
     260            3 :                     <Switch isChecked={this.props.selinuxStatus.enforcing}
     261            3 :                             label={_("Enforcing")}
     262            3 :                             onChange={this.props.changeSelinuxMode} />
     263            3 :                 </Flex>
     264            3 :                 { note !== null &&
     265            2 :                     <Content component={ContentVariants.p}>
     266            2 :                         <Icon isInline status="info"><InfoCircleIcon /></Icon>
     267            2 :                         { "\n" }
     268            2 :                         { note }
     269            2 :                     </Content>
     270              :                 }
     271            3 :                 {errorMessage}
     272            3 :             </Stack>
     273              :         );
     274            3 :     }
     275            3 : }
     276              : 
     277              : /* The listing only shows if we have a connection to the dbus API
     278              :  * Otherwise we have blank slate: trying to connect, error
     279              :  * Expected properties:
     280              :  * connected    true if the client is connected to setroubleshoot-server via dbus
     281              :  * error        error message to show (in EmptyState if not connected, as a dismissable alert otherwise
     282              :  * dismissError callback, triggered for the dismissable error in connected state
     283              :  * deleteAlert  callback, triggered with an alert id as parameter to trigger deletion
     284              :  * entries   setroubleshoot entries
     285              :  *  - runFix      function to run fix
     286              :  *  - details     fix details as provided by the setroubleshoot client
     287              :  *  - description brief description of the error
     288              :  *  - count       how many times (>= 1) this alert occurred
     289              :  * selinuxStatus      status of selinux on the system, properties as defined in selinux-client.js
     290              :  * selinuxStatusError error message from reading or setting selinux status/mode
     291              :  * changeSelinuxMode  function to use for changing the selinux enforcing mode
     292              :  * dismissStatusError function that is triggered to dismiss the selinux status error
     293              :  */
     294            3 : export class SETroubleshootPage extends React.Component {
     295            3 :     constructor(props) {
     296            3 :         super(props);
     297            3 :         this.state = { selected: {} };
     298            3 :         this.handleDismissError = this.handleDismissError.bind(this);
     299            3 :         this.onSelect = this.onSelect.bind(this);
     300            3 :     }
     301              : 
     302            0 :     handleDismissError(e) {
     303              :         // only consider primary mouse button
     304            0 :         if (!e || e.button !== 0)
     305            0 :             return;
     306            0 :         if (this.props.dismissError)
     307            0 :             this.props.dismissError();
     308            0 :         e.stopPropagation();
     309            0 :     }
     310              : 
     311            0 :     onSelect(_, isSelected, rowId) {
     312            0 :         this.setState(prevState => ({
     313            0 :             selected: { ...prevState.selected, [this.props.entries[rowId].key]: isSelected }
     314            0 :         }));
     315            0 :     }
     316              : 
     317            3 :     render() {
     318              :         // if selinux is disabled, we only show EmptyState
     319            1 :         if (this.props.selinuxStatus.enabled === false) {
     320            1 :             return <EmptyStatePanel icon={ ExclamationCircleIcon } title={ _("SELinux is disabled on the system") } />;
     321            1 :         }
     322            3 :         const self = this;
     323            3 :         const title = _("SELinux access control errors");
     324            3 :         const emptyCaption = _("No SELinux alerts.");
     325            3 :         let emptyState;
     326            3 :         let entries;
     327            3 :         if (!this.props.connected) {
     328            3 :             if (this.props.connecting) {
     329            3 :                 emptyState = <EmptyStatePanel paragraph={ _("Connecting to SETroubleshoot daemon...") } loading />;
     330            1 :             } else {
     331              :                 // if we don't have setroubleshoot-server, be more subtle about saying that
     332            1 :                 emptyState = <EmptyStatePanel icon={ InfoCircleIcon }
     333            1 :                                               paragraph={_("Install setroubleshoot-server to troubleshoot SELinux events.")} />;
     334            1 :             }
     335            2 :         } else {
     336            2 :             entries = this.props.entries.map(function(itm, index) {
     337            2 :                 itm.runFix = self.props.runFix;
     338            2 :                 let listingDetail;
     339            2 :                 if (itm.details && 'firstSeen' in itm.details) {
     340            2 :                     if (itm.details.reportCount >= 2) {
     341            2 :                         listingDetail = cockpit.format(_("Occurred between $0 and $1"),
     342            2 :                                                        timeformat.dateTime(itm.details.firstSeen),
     343            2 :                                                        timeformat.dateTime(itm.details.lastSeen)
     344            2 :                         );
     345            1 :                     } else {
     346            1 :                         listingDetail = cockpit.format(_("Occurred $0"), timeformat.dateTime(itm.details.firstSeen));
     347            1 :                     }
     348            2 :                 }
     349            2 :                 const tabRenderers = [
     350            2 :                     {
     351            2 :                         name: _("Solutions"),
     352            2 :                         renderer: SELinuxEventDetails,
     353            2 :                         data: itm,
     354            2 :                     },
     355            2 :                     {
     356            2 :                         name: _("Audit log"),
     357            2 :                         renderer: SELinuxEventLog,
     358            2 :                         data: itm,
     359            2 :                     },
     360            2 :                 ];
     361              :                 // if the alert has level "red", it's critical
     362            2 :                 const criticalAlert = (itm.details && 'level' in itm.details && itm.details.level == "red")
     363            1 :                     ? <ExclamationTriangleIcon className="ct-icon-exclamation-triangle pf-v6-c-icon pf-m-lg" />
     364            2 :                     : null;
     365            2 :                 const columns = [
     366            2 :                     { title: criticalAlert },
     367            2 :                     { title: itm.description }
     368            2 :                 ];
     369            2 :                 if (itm.count > 1) {
     370            2 :                     columns.push({ title: <Badge isRead>{itm.count}</Badge>, props: { className: "pf-v6-c-table__action" } });
     371            1 :                 } else {
     372            1 :                     columns.push({ title: <span />, props: { className: "pf-v6-c-table__action" } });
     373            1 :                 }
     374            1 :                 const rowId = itm.details ? itm.details.localId : index;
     375            2 :                 return ({
     376            2 :                     props: { key: rowId, "data-row-id": rowId },
     377            1 :                     selected: self.state.selected[itm.details ? itm.details.localId : index],
     378            2 :                     disableSelection: !itm.details,
     379            2 :                     columns,
     380            2 :                     expandedContent: <ListingPanel tabRenderers={tabRenderers}
     381            2 :                                                    listingDetail={listingDetail} />
     382            2 :                 });
     383            2 :             });
     384            2 :         }
     385            3 :         let selectedCnt = 0;
     386            1 :         for (const k in this.state.selected) if (this.state.selected[k]) selectedCnt++;
     387            0 :         const onDeleteClick = () => {
     388            0 :             for (const k in this.state.selected)
     389            0 :                 if (this.state.selected[k])
     390            0 :                     this.props.deleteAlert(k).then(() => this.setState(prevState => ({ selected: { ...prevState.selected, [k]: false } })));
     391            0 :         };
     392            3 :         const actions = (
     393            3 :             !emptyState
     394            2 :                 ? <Button className="selinux-alert-dismiss"
     395            2 :                 variant="danger"
     396            2 :                 onClick={onDeleteClick}
     397            2 :                 isDisabled={ !this.props.deleteAlert || !selectedCnt}>
     398            1 :                     {selectedCnt ? cockpit.format(cockpit.ngettext("Dismiss $0 alert", "Dismiss $0 alerts", selectedCnt), selectedCnt) : _("Dismiss selected alerts")}
     399            2 :                 </Button>
     400            3 :                 : null
     401              :         );
     402            3 :         const troubleshooting = (
     403            3 :             <Card isPlain>
     404            3 :                 <CardHeader actions={{ actions }}>
     405            3 :                     <CardTitle component="h2">{title}</CardTitle>
     406            3 :                 </CardHeader>
     407            3 :                 <CardBody className="contains-list">
     408            3 :                     {!emptyState
     409            2 :                         ? <ListingTable aria-label={ title }
     410            2 :                                   id="selinux-alerts"
     411            2 :                                   onSelect={this.onSelect}
     412            2 :                                   gridBreakPoint=''
     413            2 :                                   emptyCaption={ emptyCaption }
     414            2 :                                   columns={[{ title: _("Alert") }, { title: _("Error message"), header: true }, { title: _("Occurrences") }]}
     415            2 :                                   showHeader={false}
     416            2 :                                   variant="compact"
     417            2 :                                   rows={entries} />
     418            3 :                         : emptyState}
     419            3 :                 </CardBody>
     420            3 :             </Card>
     421              :         );
     422              : 
     423            3 :         const modifications = (
     424            3 :             <Modifications
     425            3 :                 title={ _("System modifications") }
     426            3 :                 permitted={ this.props.selinuxStatus.permitted }
     427            3 :                 shell={ "semanage import <<EOF\n" + this.props.selinuxStatus.shell.trim() + "\nEOF" }
     428            3 :                 ansible={ this.props.selinuxStatus.ansible }
     429            3 :                 entries={ this.props.selinuxStatus.modifications }
     430            1 :                 failed={this.props.selinuxStatus.failed ? _("Error running semanage to discover system modifications") : null}
     431            3 :             />
     432              :         );
     433              : 
     434            3 :         let errorMessage;
     435            2 :         if (this.props.error) {
     436            2 :             errorMessage = (
     437            2 :                 <AlertGroup isToast>
     438            2 :                     <Alert
     439            2 :                         isLiveRegion
     440            2 :                         variant='danger' title={this.props.error}
     441            2 :                         actionClose={<AlertActionCloseButton onClose={this.handleDismissError} />} />
     442            2 :                 </AlertGroup>
     443              :             );
     444            2 :         }
     445              : 
     446            3 :         return (
     447            3 :             <>
     448            3 :                 {errorMessage}
     449            3 :                 <Page className="pf-m-no-sidebar">
     450            3 :                     <PageSection hasBodyWrapper={false} padding={{ default: "padding" }}>
     451            3 :                         <SELinuxStatus
     452            3 :                             selinuxStatus={this.props.selinuxStatus}
     453            3 :                             selinuxStatusError={this.props.selinuxStatusError}
     454            3 :                             changeSelinuxMode={this.props.changeSelinuxMode}
     455            3 :                             dismissError={this.props.dismissStatusError}
     456            3 :                         />
     457            3 :                     </PageSection>
     458            3 :                     <PageSection hasBodyWrapper={false}>
     459            3 :                         <Stack hasGutter>
     460            3 :                             <StackItem>{modifications}</StackItem>
     461            3 :                             <StackItem>{troubleshooting}</StackItem>
     462            3 :                         </Stack>
     463            3 :                     </PageSection>
     464            3 :                 </Page>
     465            3 :             </>
     466              :         );
     467            3 :     }
     468            3 : }
        

Generated by: LCOV version 2.0-1