Vulnerability dashboard: Add new homepage (#35253)

Related to: https://github.com/fleetdm/fleet/issues/33661

Changes:
- Updated the homepage of the vulnerability dashboard to be a new
dashboard page.
- Updated the `Vulnerability` model to make cveID a unique value.
This commit is contained in:
Eric 2025-11-07 12:01:57 -06:00 committed by GitHub
parent 3772ccfaa2
commit 48a26f3fe5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2113 additions and 3 deletions

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ module.exports = {
installedAt: {
example: 1670152500000,
description: 'JS timestamp representing when this installation began on the host.',
extendedDescription: 'This JS timestamp represents when the vulnerabiltiy dashboard first saw this vulnerable software on a host',
type: 'number',
isInteger: true,
required: true,

View file

@ -0,0 +1,156 @@
parasails.registerPage('dashboard', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
//…
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
Chart.defaults.color = '#d4d4d8';
Chart.defaults.borderColor = '#21262d';
new Chart(document.getElementById('severityChart'), {
type: 'doughnut',
data: {
labels: ['Critical', 'High', 'Medium', 'Low'],
datasets: [{
data: [this.totalUniqueCounts.critical, this.totalUniqueCounts.high, this.totalUniqueCounts.medium, this.totalUniqueCounts.low],
backgroundColor: ['#dc2626', '#ea580c', '#ca8a04', '#16a34a'],
borderWidth: 2,
borderColor: '#010409'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true,
color: '#d4d4d8'
}
}
}
}
});
new Chart(document.getElementById('trendsChart'), {
type: 'line',
data: {
// labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4'],
labels: _.pluck(this.activityTrendsTimeline, 'timelineLabel'),
datasets: [{
label: 'New CVEs',
data: _.pluck(this.activityTrendsTimeline, 'newCves'),
borderColor: '#dc2626',
backgroundColor: 'rgba(220, 38, 38, 0.1)',
tension: 0.4,
borderWidth: 2,
pointBackgroundColor: '#dc2626',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 5
}, {
label: 'Remediated CVEs',
data: _.pluck(this.activityTrendsTimeline, 'remediatedCves'),
borderColor: '#16a34a',
backgroundColor: 'rgba(22, 163, 74, 0.1)',
tension: 0.4,
borderWidth: 2,
pointBackgroundColor: '#16a34a',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 5
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
grid: { color: '#21262d' },
ticks: { color: '#7d8590' }
},
x: {
grid: { color: '#21262d' },
ticks: { color: '#7d8590' }
}
},
plugins: {
legend: {
labels: {
color: '#d4d4d8',
usePointStyle: true,
padding: 20
}
}
}
}
});
new Chart(document.getElementById('totalTrendChart'), {
type: 'line',
data: {
labels: _.pluck(this.activityTrendsTimeline, 'timelineLabel'),
datasets: [{
label: 'Total CVEs',
data: _.pluck(this.activityTrendsTimeline, 'totalNumberOfCves'),
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
tension: 0.4,
borderWidth: 3,
pointBackgroundColor: '#8b5cf6',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 6,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: false,
min: 25000,
grid: { color: '#21262d' },
ticks: {
color: '#7d8590',
callback: function(value) {
return (value / 1000).toFixed(1) + 'k';
}
}
},
x: {
grid: { color: '#21262d' },
ticks: { color: '#7d8590' }
}
},
plugins: {
legend: {
labels: {
color: '#d4d4d8',
usePointStyle: true,
padding: 20
}
}
}
}
});
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
//…
}
});

View file

@ -44,3 +44,4 @@
@import 'pages/500.less';
@import 'pages/498.less';
@import 'pages/patch-progress.less';
@import 'pages/dashboard.less';

View file

@ -40,7 +40,11 @@ html, body {
// ^^The above is to disable "importantRule" and "duplicateProperty" rules.
min-height: 100%;
position: relative;
padding-bottom: @footer-height;
&.header-hidden {
padding-bottom: 0px;
}
background-color: #f8f9fa;
color: #292b2d;
a {

View file

@ -0,0 +1,280 @@
#dashboard {
background: #111111;
.dashboard-container {
max-width: 1600px;
margin: 0 auto;
background: #111111;
border-radius: 12px;
padding: 30px;
// border: 1px solid #1f1f1f;
}
.dashboard-header {
text-align: center;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 1px solid #1f1f1f;
}
.dashboard-links {
a {
color: #FFF;
&::hover {
color: #FFF;
}
}
}
.dashboard-title {
font-size: 2.2em;
font-weight: 300;
color: #ffffff;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.dashboard-subtitle {
color: #7d8590;
font-size: 0.9em;
font-weight: 300;
}
.metrics-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.main-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 25px;
margin-bottom: 30px;
}
.chart-row {
display: grid;
// grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 25px;
margin-bottom: 30px;
}
.full-width {
grid-column: 1 / -1;
}
.panel {
background: #0d1117;
border-radius: 8px;
padding: 24px;
border: 1px solid #1f2328;
transition: all 0.2s ease;
min-width: 0px;
}
.panel:hover {
border-color: #2563eb;
background: #161b22;
}
.metric-card {
background: #0d1117;
border-radius: 8px;
padding: 20px;
border: 1px solid #1f2328;
text-align: center;
transition: all 0.2s ease;
}
.metric-card:hover {
border-color: #2563eb;
transform: translateY(-2px);
}
.metric-value {
font-size: 2.5em;
font-weight: 300;
color: #ffffff;
line-height: 1;
margin-bottom: 8px;
}
.metric-label {
font-size: 0.85em;
color: #7d8590;
font-weight: 300;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.severity-critical { color: #ef4444; }
.severity-high { color: #f97316; }
.severity-medium { color: #eab308; }
.severity-low { color: #22c55e; }
.panel-title {
font-size: 1.1em;
font-weight: 400;
color: #ffffff;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #1f2328;
}
.host-list {
list-style: none;
padding-inline-start: 0px;
}
.host-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #1f2328;
font-weight: 300;
}
.host-item:last-child {
border-bottom: none;
}
.host-name {
color: #d4d4d8;
font-size: 0.9em;
a {
color: unset;
}
}
.vulnerability-score {
background: #ef4444;
color: white;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 400;
}
.software-list {
list-style: none;
padding-inline-start: 0px;
}
.software-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #1f2328;
}
.software-item:last-child {
border-bottom: none;
}
.software-name {
color: #d4d4d8;
font-size: 0.9em;
font-weight: 300;
a {
color: unset;
}
}
.vuln-count {
background: #21262d;
color: #d4d4d8;
padding: 3px 8px;
border-radius: 8px;
font-size: 0.8em;
white-space: nowrap;
}
.chart-container {
position: relative;
height: 300px;
background: #010409;
border-radius: 6px;
padding: 15px;
border: 1px solid #0d1117;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.remediation-summary {
margin-bottom: 30px;
}
.remediation-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
text-align: center;
}
.status-external { background: #ef4444; }
@media (max-width: 1200px) {
.chart-panel {
grid-column: 1 / span 2;
}
}
@media (max-width: 991px) {
.main-grid {
display: flex;
flex-direction: column;
}
.metrics-row {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.chart-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
// .main-grid {
// grid-template-columns: 1fr;
// }
.chart-row {
grid-template-columns: 1fr;
}
.dashboard-container {
padding: 20px;
padding-bottom: 40px;
}
}
@media (max-width: 576px) {
.main-grid {
grid-template-columns: 1fr;
}
.remediation-metrics {
display: flex;
flex-direction: column;
}
.metrics-row {
display: flex;
flex-direction: column;
}
}
}

View file

@ -14,7 +14,7 @@ module.exports.routes = {
// ║║║║╣ ╠╩╗╠═╝╠═╣║ ╦║╣ ╚═╗
// ╚╩╝╚═╝╚═╝╩ ╩ ╩╚═╝╚═╝╚═╝
// 'GET /': { action: 'view-homepage-or-redirect' },
'GET /dashboard': { action: 'dashboard/view-welcome' },
// 'GET /dashboard': { action: 'dashboard/view-welcome' },
'GET /vulnerability-list': { action: 'dashboard/view-vulnerability-list' },
'GET /patch-progress': { action: 'view-patch-progress' },
@ -35,7 +35,12 @@ module.exports.routes = {
'GET /account': { action: 'account/view-account-overview' },
'GET /account/password': { action: 'account/view-edit-password' },
'GET /account/profile': { action: 'account/view-edit-profile' },
'GET /dashboard': {
action: 'view-dashboard',
locals: {
headerHidden: true,
}
},
// ╔╦╗╦╔═╗╔═╗ ╦═╗╔═╗╔╦╗╦╦═╗╔═╗╔═╗╔╦╗╔═╗ ┬ ╔╦╗╔═╗╦ ╦╔╗╔╦ ╔═╗╔═╗╔╦╗╔═╗
// ║║║║╚═╗║ ╠╦╝║╣ ║║║╠╦╝║╣ ║ ║ ╚═╗ ┌┼─ ║║║ ║║║║║║║║ ║ ║╠═╣ ║║╚═╗

View file

@ -2,6 +2,7 @@
// In case we're displaying the 404 or 500 page and relevant code in the "custom" hook was not able to run,
// we make sure `me` exists. This ensures we don't have to do `typeof` checks below.
var me = undefined;
var headerHidden;
}
let replaceBuiltInAuthWithOkta = (sails.config.custom.oktaClientSecret !== undefined);
if(typeof replaceBuiltInAuthWithOkta === 'undefined') {
@ -53,7 +54,8 @@
<!--STYLES END-->
</head>
<body>
<div purpose="page-wrap">
<div purpose="page-wrap" class="<%- headerHidden ? 'header-hidden' : '' %>">
<%if(!headerHidden) {%>
<header class="navbar navbar-expand-sm navbar flex-row justify-content-between align-items-center" purpose="page-header">
<a style="cursor: pointer;" class="navbar-brand mr-0" href="/"><img style="height: 20px;" class="logo" alt="NEW_APP_NAME logo" src="images/fleet-logo-black-118x40@2x.png"/></a>
<div class="navbar-nav flex-row d-none d-md-flex">
@ -95,6 +97,7 @@
</div>
</div>
</header>
<% } %>
<%- body %>
@ -151,6 +154,7 @@
<script src="/js/pages/account/edit-password.page.js"></script>
<script src="/js/pages/account/edit-profile.page.js"></script>
<script src="/js/pages/contact.page.js"></script>
<script src="/js/pages/dashboard.page.js"></script>
<script src="/js/pages/dashboard/vulnerability-list.page.js"></script>
<script src="/js/pages/dashboard/welcome.page.js"></script>
<script src="/js/pages/entrance/confirmed-email.page.js"></script>

View file

@ -0,0 +1,183 @@
<div id="dashboard" v-cloak>
<div class="dashboard-container container-fluid d-flex flex-column">
<div class="dashboard-header">
<h1 class="dashboard-title">Lets get to a better state</h1>
<p class="dashboard-subtitle">Last Updated: <js-timestamp :at="lastUpdatedAt"></js-timestamp></p>
<div class="dashboard-links d-flex flex-row justify-content-center">
<p class="mr-3"><a href="/vulnerability-list">Vulnerability list</a></p>
<p><a href="/patch-progress">Patch progress</a></p>
</div>
</div>
<div class="metrics-row">
<div class="metric-card">
<div class="metric-value severity-critical">{{(totalUniqueCounts.critical).toLocaleString()}}</div>
<div class="metric-label">Unique critical</div>
</div>
<div class="metric-card">
<div class="metric-value severity-high">{{(totalUniqueCounts.high).toLocaleString()}}</div>
<div class="metric-label">Unique high</div>
</div>
<div class="metric-card">
<div class="metric-value severity-medium">{{(totalUniqueCounts.medium).toLocaleString()}}</div>
<div class="metric-label">Unique medium</div>
</div>
<div class="metric-card">
<div class="metric-value severity-low">{{(totalUniqueCounts.low).toLocaleString()}}</div>
<div class="metric-label">Unique low</div>
</div>
</div>
<div class="metrics-row">
<div class="metric-card">
<div class="metric-value severity-critical">{{(totalNumberOfVulns.critical).toLocaleString()}}</div>
<div class="metric-label">Total critical</div>
</div>
<div class="metric-card">
<div class="metric-value severity-high">{{(totalNumberOfVulns.high).toLocaleString()}}</div>
<div class="metric-label">Total high</div>
</div>
<div class="metric-card">
<div class="metric-value severity-medium">{{(totalNumberOfVulns.medium).toLocaleString()}}</div>
<div class="metric-label">Total medium</div>
</div>
<div class="metric-card">
<div class="metric-value severity-low">{{(totalNumberOfVulns.low).toLocaleString()}}</div>
<div class="metric-label">Total low</div>
</div>
</div>
<div class="main-grid">
<div class="panel">
<h2 class="panel-title">The riskiest hosts right now</h2>
<p><small class="text-white">Host risk scores range from 0-10. The score weighs the number and severity of CVEs, exploit availability (public exploits double the weight). A "10" means urgent, drop-everything action: critical vulnerabilities with known exploits and high exposure.</small></p>
<ul class="host-list">
<li class="host-item" v-for="host in mostVulnerableHosts">
<span class="host-name"><a :href="host.fleetUrl" target="_blank">{{host.displayName}}</a></span>
<span class="vulnerability-score">{{host.pps}}</span>
</li>
</ul>
</div>
<div class="panel">
<h2 class="panel-title">The usual suspects in software</h2>
<ul class="software-list">
<li class="software-item" v-for="software in mostVulnerableSoftware">
<span class="software-name"><a :href="software.fleetUrl" target="_blank">{{software.softwareNameAndVersion}}</a></span>
<span class="vuln-count">{{software.numberOfVulns}} CVEs</span>
</li>
</ul>
</div>
<div class="panel chart-panel">
<h2 class="panel-title">What percentage are critical?</h2>
<div class="chart-container">
<canvas id="severityChart"></canvas>
</div>
</div>
</div>
<div class="chart-row">
<div class="panel">
<h2 class="panel-title">Who installed this? (Critically rare software)</h2>
<ul class="software-list">
<li class="software-item" v-for="software in uniqueCriticalSoftware">
<!-- <span class="software-name"><a :href="software.fleetUrl" target="_blank">{{software.softwareNameAndVersion}}</a></span> -->
<span class="software-name">{{software.softwareNameAndVersion}}</span>
<span class="vuln-count">{{software.numberOfVulns}} critical</span>
</li>
</ul>
</div>
<div class="panel">
<h2 class="panel-title">Hosts with most CVEs</h2>
<ul class="host-list">
<li class="host-item" v-for="host in hostsWithMostVulns">
<span class="host-name"><a :href="host.fleetUrl" target="_blank">{{host.displayName}}</a></span>
<span class="vulnerability-score">{{host.numberOfVulns}}</span>
</li>
</ul>
</div>
</div>
<div class="chart-row">
<div class="panel">
<h2 class="panel-title">CVE activity trends</h2>
<div class="chart-container" style="height: 250px;">
<canvas id="trendsChart"></canvas>
</div>
</div>
<div class="panel">
<h2 class="panel-title">Total CVE count trend</h2>
<div class="chart-container" style="height: 250px;">
<canvas id="totalTrendChart"></canvas>
</div>
</div>
</div>
<div class="panel full-width remediation-summary">
<h2 class="panel-title">CVE remediation summary (past 90 Days)</h2>
<div class="remediation-metrics">
<div>
<div style="font-size: 2em; color: #ef4444; font-weight: 300;">+{{remediationSummary.newCvesNinetyDays}}</div>
<div style="color: #7d8590; font-size: 0.9em;">New CVEs (90d)</div>
</div>
<div>
<div style="font-size: 2em; color: #22c55e; font-weight: 300;">-{{remediationSummary.remediatedCvesNinetyDays}}</div>
<div style="color: #7d8590; font-size: 0.9em;">Remediated (90d)</div>
</div>
<div>
<div style="font-size: 2em; color: #ef4444; font-weight: 300;">+{{remediationSummary.newCvesThirtyDays}}</div>
<div style="color: #7d8590; font-size: 0.9em;">New CVEs (30d)</div>
</div>
<div>
<div style="font-size: 2em; color: #22c55e; font-weight: 300;">-{{remediationSummary.remediatedCvesThirtyDays}}</div>
<div style="color: #7d8590; font-size: 0.9em;">Remediated (30d)</div>
</div>
</div>
</div>
<!-- Exploitable CVE Analysis Row -->
<div class="metrics-row">
<div class="panel">
<h2 class="panel-title">Top 5 oldest CVEs with exploits</h2>
<ul class="software-list">
<li class="software-item" v-for="cve in oldestExploitableCves">
<span class="software-name"><a :href="cve.fleetUrl" target="_blank">{{cve.cveId}}</a></span>
<span class="vuln-count" style="background: #dc2626;">{{cve.yearPublished}}</span>
</li>
</ul>
</div>
<div class="panel">
<h2 class="panel-title">Top 5 hosts with oldest exploitable CVEs</h2>
<ul class="host-list">
<li class="host-item" v-for="host in oldestExploitableHosts">
<span class="host-name"><a :href="host.fleetUrl" target="_blank">{{host.displayName}}</a></span>
<span class="vulnerability-score" style="background: #7c2d12;">{{host.yearPublished}}</span>
</li>
</ul>
</div>
<div class="panel">
<h2 class="panel-title">Top 5 newest CVEs with exploits</h2>
<ul class="software-list">
<li class="software-item" v-for="cve in newestExploitableCves">
<span class="software-name"><a :href="cve.fleetUrl" target="_blank">{{cve.cveId}}</a></span>
<span class="vuln-count" style="background: #dc2626;">{{cve.yearPublished}}</span>
</li>
</ul>
</div>
<div class="panel">
<h2 class="panel-title">Top 5 hosts with newest exploitable CVEs</h2>
<ul class="host-list">
<li class="host-item" v-for="host in newestExploitableHosts">
<span class="host-name"><a :href="host.fleetUrl" target="_blank">{{host.displayName}}</a></span>
<span class="vulnerability-score" style="background: #7c2d12;">{{host.yearPublished}}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<%- /* Expose server-rendered data as window.SAILS_LOCALS :: */ exposeLocalsToBrowser() %>