387 lines
11 KiB
JavaScript
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,
|
|
};
|