pgadmin4/web/pgadmin/llm/static/js/SecurityReport.jsx

387 lines
11 KiB
JavaScript

/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { useState, useEffect } from 'react';
import { Box, Paper, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import DownloadIcon from '@mui/icons-material/Download';
import RefreshIcon from '@mui/icons-material/Refresh';
import PropTypes from 'prop-types';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import gettext from 'sources/gettext';
import url_for from 'sources/url_for';
import getApiInstance from '../../../static/js/api_instance';
import Loader from '../../../static/js/components/Loader';
import { PrimaryButton, DefaultButton } from '../../../static/js/components/Buttons';
import { usePgAdmin } from '../../../static/js/PgAdminProvider';
// Configure marked for security and rendering
marked.setOptions({
gfm: true, // GitHub Flavored Markdown
breaks: true, // Convert \n to <br>
});
const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
background: theme.palette.grey[400],
'& .SecurityReport-header': {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: theme.spacing(1, 2),
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.default,
},
'& .SecurityReport-actions': {
display: 'flex',
gap: theme.spacing(1),
},
'& .SecurityReport-content': {
flex: 1,
overflow: 'auto',
padding: theme.spacing(3),
position: 'relative',
display: 'flex',
justifyContent: 'center',
},
'& .SecurityReport-paper': {
width: '100%',
maxWidth: '900px',
minHeight: 'fit-content',
},
'& .SecurityReport-markdown': {
...theme.mixins.panelBorder.all,
backgroundColor: theme.palette.background.default,
fontFamily: theme.typography.fontFamily,
fontSize: '0.9rem',
lineHeight: 1.6,
padding: theme.spacing(4),
boxShadow: theme.shadows[2],
userSelect: 'text',
cursor: 'text',
'& h1': {
fontSize: '1.5rem',
fontWeight: 600,
marginTop: theme.spacing(2),
marginBottom: theme.spacing(1),
borderBottom: `1px solid ${theme.palette.divider}`,
paddingBottom: theme.spacing(0.5),
},
'& h2': {
fontSize: '1.25rem',
fontWeight: 600,
marginTop: theme.spacing(2),
marginBottom: theme.spacing(1),
},
'& h3': {
fontSize: '1.1rem',
fontWeight: 600,
marginTop: theme.spacing(1.5),
marginBottom: theme.spacing(0.5),
},
'& p': {
marginTop: 0,
marginBottom: theme.spacing(1.5),
},
'& ul, & ol': {
marginTop: 0,
marginBottom: theme.spacing(1.5),
paddingLeft: theme.spacing(3),
},
'& ul ul, & ol ol, & ul ol, & ol ul': {
marginBottom: 0,
},
'& li': {
marginBottom: theme.spacing(0.5),
'& > p': {
marginBottom: theme.spacing(0.5),
},
},
'& li > ul, & li > ol': {
marginTop: theme.spacing(0.5),
},
// Task list checkboxes (GitHub style)
'& input[type="checkbox"]': {
marginRight: theme.spacing(0.5),
},
'& code': {
backgroundColor: theme.palette.action.hover,
padding: '2px 6px',
borderRadius: '3px',
fontFamily: 'monospace',
fontSize: '0.85em',
},
'& pre': {
backgroundColor: theme.palette.action.hover,
padding: theme.spacing(1.5),
borderRadius: '4px',
overflow: 'auto',
'& code': {
backgroundColor: 'transparent',
padding: 0,
},
},
'& blockquote': {
borderLeft: `4px solid ${theme.palette.primary.main}`,
margin: theme.spacing(1.5, 0),
padding: theme.spacing(1, 2),
backgroundColor: theme.palette.action.hover,
'& p:last-child': {
marginBottom: 0,
},
},
'& table': {
borderCollapse: 'collapse',
width: '100%',
marginBottom: theme.spacing(1.5),
display: 'block',
overflowX: 'auto',
},
'& thead': {
display: 'table',
width: '100%',
tableLayout: 'fixed',
},
'& tbody': {
display: 'table',
width: '100%',
tableLayout: 'fixed',
},
'& tr': {
borderBottom: `1px solid ${theme.palette.divider}`,
},
'& th, & td': {
border: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(1, 1.5),
textAlign: 'left',
verticalAlign: 'top',
},
'& th': {
backgroundColor: theme.palette.action.hover,
fontWeight: 600,
},
'& tbody tr:hover': {
backgroundColor: theme.palette.action.hover,
},
'& hr': {
border: 'none',
borderTop: `1px solid ${theme.palette.divider}`,
margin: theme.spacing(2, 0),
},
'& strong': {
fontWeight: 600,
},
'& em': {
fontStyle: 'italic',
},
},
'& .SecurityReport-error': {
...theme.mixins.panelBorder.all,
backgroundColor: theme.palette.background.default,
color: theme.palette.error.main,
padding: theme.spacing(4),
textAlign: 'center',
width: '100%',
maxWidth: '900px',
boxShadow: theme.shadows[2],
},
'& .SecurityReport-placeholder': {
...theme.mixins.panelBorder.all,
backgroundColor: theme.palette.background.default,
color: theme.palette.text.secondary,
padding: theme.spacing(4),
textAlign: 'center',
width: '100%',
maxWidth: '900px',
boxShadow: theme.shadows[2],
},
}));
export default function SecurityReport({
sid, did, scid, reportType = 'server',
serverName, databaseName, schemaName,
onClose: _onClose
}) {
const [report, setReport] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const pgAdmin = usePgAdmin();
const api = getApiInstance();
// Build the API URL based on report type
const getReportUrl = () => {
if (reportType === 'schema') {
return url_for('llm.schema_security_report', { sid, did, scid });
} else if (reportType === 'database') {
return url_for('llm.database_security_report', { sid, did });
} else {
return url_for('llm.security_report', { sid });
}
};
const generateReport = () => {
setLoading(true);
setError(null);
setReport('');
api.get(getReportUrl())
.then((res) => {
if (res.data && res.data.success) {
setReport(res.data.data?.report || '');
} else {
setError(res.data?.errormsg || gettext('Failed to generate security report.'));
}
})
.catch((err) => {
let errMsg = gettext('Failed to generate security report.');
if (err.response?.data?.errormsg) {
errMsg = err.response.data.errormsg;
} else if (err.message) {
errMsg = err.message;
}
setError(errMsg);
pgAdmin.Browser.notifier.error(errMsg);
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
// Generate report on mount
generateReport();
}, [sid, did, scid, reportType]);
// Build markdown header for the report
const getReportHeader = () => {
let title, subtitle;
if (reportType === 'schema') {
title = gettext('Schema Security Report') + ': ' + schemaName;
subtitle = `${schemaName} ${gettext('in')} ${databaseName} ${gettext('on')} ${serverName}`;
} else if (reportType === 'database') {
title = gettext('Database Security Report') + ': ' + databaseName;
subtitle = `${databaseName} ${gettext('on')} ${serverName}`;
} else {
title = gettext('Server Security Report') + ': ' + serverName;
subtitle = serverName;
}
const date = new Date().toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return `# ${title}\n\n*${subtitle}${date}*\n\n---\n\n`;
};
// Build filename for download based on report type
const getDownloadFilename = () => {
const date = new Date().toISOString().slice(0, 10);
const sanitize = (str) => str ? str.replace(/[^a-z0-9]/gi, '_') : '';
if (reportType === 'schema') {
return `security-report-${sanitize(schemaName)}-${sanitize(databaseName)}-${sanitize(serverName)}-${date}.md`;
} else if (reportType === 'database') {
return `security-report-${sanitize(databaseName)}-${sanitize(serverName)}-${date}.md`;
} else {
return `security-report-${sanitize(serverName)}-${date}.md`;
}
};
const handleDownload = () => {
if (!report) return;
const blob = new Blob([getReportHeader() + report], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getDownloadFilename();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const reportHtml = report
? DOMPurify.sanitize(marked.parse(getReportHeader() + report))
: '';
return (
<StyledBox>
<Box className="SecurityReport-header">
<Box className="SecurityReport-actions">
<DefaultButton
onClick={generateReport}
disabled={loading}
startIcon={<RefreshIcon />}
>
{gettext('Regenerate')}
</DefaultButton>
<PrimaryButton
onClick={handleDownload}
disabled={!report || loading}
startIcon={<DownloadIcon />}
>
{gettext('Download')}
</PrimaryButton>
</Box>
</Box>
<Box className="SecurityReport-content">
<Loader message={loading ? gettext('Generating security report') : null} autoEllipsis />
{error && !loading && (
<Box className="SecurityReport-error">
<Typography variant="body1">{error}</Typography>
<DefaultButton onClick={generateReport} style={{ marginTop: '16px' }}>
{gettext('Retry')}
</DefaultButton>
</Box>
)}
{!report && !loading && !error && (
<Box className="SecurityReport-placeholder">
<Typography variant="body1">
{gettext('Click "Generate" to create a security report for this server.')}
</Typography>
</Box>
)}
{report && !loading && (
<Box className="SecurityReport-paper">
<Paper elevation={0} className="SecurityReport-markdown">
<div dangerouslySetInnerHTML={{ __html: reportHtml }} />
</Paper>
</Box>
)}
</Box>
</StyledBox>
);
}
SecurityReport.propTypes = {
sid: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
did: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
scid: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
reportType: PropTypes.oneOf(['server', 'database', 'schema']),
serverName: PropTypes.string.isRequired,
databaseName: PropTypes.string,
schemaName: PropTypes.string,
onClose: PropTypes.func,
};