mirror of
https://github.com/fleetdm/fleet
synced 2026-05-02 19:07:38 +00:00
Closes: https://github.com/fleetdm/confidential/issues/4057 Changes: - Added the contents of the fleet-vulnerability-dashboard repo to ee/vulnerability-dashboard - Added a github workflow to deploy the vulnerability dashboard on Heroku - Added a github workflow to test changes to the vulnerability-dashboard - Updated the website's custom configuration to enable auto-approvals/review requests to files in the ee/vulnerability-dashboard folder
307 lines
12 KiB
JavaScript
307 lines
12 KiB
JavaScript
module.exports = {
|
|
|
|
|
|
friendlyName: 'Get vulnerabilities',
|
|
|
|
|
|
description: 'Get sorted & paginated vulnerabilities that match the provided filters.',
|
|
|
|
|
|
inputs: {
|
|
|
|
minSeverity: {
|
|
description: 'Optional filter to only get vulnerabilities whose `severity` is >= the specified value.',
|
|
type: 'number',
|
|
defaultsTo: 0,
|
|
},
|
|
|
|
maxSeverity: {
|
|
description: 'Optional filter to only get vulnerabilities whose `severity` is <= the specified value.',
|
|
type: 'number',
|
|
defaultsTo: 10,
|
|
},
|
|
|
|
sortBy: {
|
|
description: 'An optional facet to sort vulnerabilities by.',
|
|
type: 'string',
|
|
isIn: [
|
|
'cveId',
|
|
'severity',
|
|
'hasKnownExploit',
|
|
'publishedAt',
|
|
'resolvedAt',
|
|
'createdAt'
|
|
],
|
|
defaultsTo: 'publishedAt'
|
|
},
|
|
|
|
sortDirection: {
|
|
type: 'string',
|
|
isIn: [
|
|
'ASC',
|
|
'DESC',
|
|
],
|
|
defaultsTo: 'DESC'
|
|
},
|
|
|
|
page: {
|
|
description: 'The zero-indexed page number.',
|
|
type: 'number',
|
|
defaultsTo: 0
|
|
},
|
|
|
|
pageSize: {
|
|
description: 'The number of vulnerabilities to return per page',
|
|
type: 'number',
|
|
defaultsTo: 40,
|
|
},
|
|
|
|
teamApid: {
|
|
description: 'The ID of the Team to filter by, or 0 to only include hosts with no team, or undefined to not filter by any team.',
|
|
type: 'number',
|
|
},
|
|
|
|
cveId: {
|
|
description: 'If provided, a report will be generated for only this CVE',
|
|
type: 'string',
|
|
},
|
|
|
|
includeResolvedInstalls: {
|
|
type: 'boolean',
|
|
description: 'Whether or not to include resolved install information in this report.',
|
|
extendedDescription: 'This is only used when generating a report for the CSV export actions.'
|
|
},
|
|
|
|
},
|
|
|
|
exits: {
|
|
|
|
success: {
|
|
outputFriendlyName: 'Report',
|
|
outputDescription: 'A dictionary with a `total` and `entries` (a list of vulnerabilities).',
|
|
outputType: {},
|
|
outputExample: {
|
|
total: 1,
|
|
entries: [
|
|
{ id: 99, /*…*/ }
|
|
]
|
|
}
|
|
},
|
|
|
|
noMatchingVulnerabilities: {
|
|
description: 'No vulnerabilities matching the provided filters were found.',
|
|
},
|
|
|
|
},
|
|
|
|
|
|
fn: async function ({minSeverity, maxSeverity, sortBy, sortDirection, page, pageSize, teamApid, cveId, includeResolvedInstalls}) {
|
|
let report = {};
|
|
|
|
// If a specific CVE ID was provided, we'll change the where clause to only return information about that CVE.
|
|
let whereClause;
|
|
if(cveId){
|
|
whereClause = { cveId };
|
|
} else {
|
|
whereClause = {
|
|
and: [
|
|
{ severity: { '>=': minSeverity } },
|
|
{ severity: { '<=': maxSeverity } },
|
|
]
|
|
};
|
|
}//∞
|
|
|
|
// Because we can't sort by resolvedAt until we've added that value, we'll sort by publishDate for now
|
|
// and set a flag that we'll use to sort the list by resolvedAt values when we have that information added.
|
|
let sortResultsyByResolveDate = false;
|
|
if(sortBy === 'resolvedAt'){
|
|
sortResultsyByResolveDate = true;
|
|
sortBy = 'publishedAt';
|
|
}
|
|
|
|
// This bit of code is performance-sensitive.
|
|
// It gathers three pieces of information:
|
|
// - report.total (total # distinct cves)
|
|
// - hostConstraint (constraing with {in: list of matching host ids})
|
|
// - vulnerabilities (list of flat vuln records)
|
|
let vulnerabilities;
|
|
let hostConstraint = { nin: [] };
|
|
if (teamApid === undefined || cveId) {
|
|
report.total = await Vulnerability.count(whereClause);
|
|
vulnerabilities = await Vulnerability.find(whereClause)
|
|
.paginate(page, pageSize)
|
|
.sort([
|
|
{ [sortBy]: sortDirection }
|
|
]);
|
|
if(vulnerabilities.length === 0){
|
|
throw 'noMatchingVulnerabilities';
|
|
}
|
|
} else {// If we're filtering by a specific team and not getting the results for a single vulnerability, we'll build a list of vulnerabilities that affect that team.
|
|
|
|
let hostIdsToFind = _.pluck(await Host.find({teamApid: teamApid}).select(['id']), 'id');
|
|
// TODO use parameterization
|
|
let firstNativeQueryToSend;
|
|
|
|
if(sails.config.datastores.default.adapter === 'sails-postgresql') {
|
|
firstNativeQueryToSend = `
|
|
SELECT * FROM "vulnerability"
|
|
WHERE "id" IN (
|
|
SELECT "vulnerability" FROM "vulnerabilityinstall"
|
|
WHERE "host" IN (${hostIdsToFind.join(',')})
|
|
);`;
|
|
} else if(sails.config.datastores.default.adapter === 'sails-mysql') {
|
|
firstNativeQueryToSend = 'SELECT * FROM `vulnerability` '+
|
|
'WHERE `id` IN ('+
|
|
'SELECT `vulnerability` FROM `vulnerabilityinstall` '+
|
|
'WHERE `host` IN ('+
|
|
hostIdsToFind.join(',')+
|
|
')'+
|
|
');';
|
|
}
|
|
|
|
|
|
let rawResultFromDatabase = await sails.sendNativeQuery(firstNativeQueryToSend);
|
|
|
|
for(let row of rawResultFromDatabase.rows){
|
|
row.createdAt = Number(row.createdAt);
|
|
}
|
|
|
|
let vulnerabilitiesAffectingThisTeam = _.uniq(rawResultFromDatabase.rows, 'cveId');
|
|
if(vulnerabilitiesAffectingThisTeam.length === 0){
|
|
throw 'noMatchingVulnerabilities';
|
|
}
|
|
|
|
// Sort the filtered array of vulnerabilities fot this team
|
|
if(sortDirection === 'ASC') {
|
|
vulnerabilities = _.sortBy(vulnerabilitiesAffectingThisTeam, sortBy);
|
|
} else {
|
|
vulnerabilities = _(_.sortBy(vulnerabilitiesAffectingThisTeam, sortBy)).reverse().value();
|
|
}
|
|
|
|
// Filter the array of vulnerabilities for this team using the provided minSeverity and maxSeverity.
|
|
let filteredVulnerabilities = _.filter(vulnerabilities, (vuln)=>{
|
|
return vuln.severity >= minSeverity && vuln.severity <= maxSeverity;
|
|
});
|
|
// If there are no vulnerabilities matching the selected filters send a noMatchingVulnerabilities response.
|
|
if(filteredVulnerabilities.length === 0){
|
|
throw 'noMatchingVulnerabilities';
|
|
}
|
|
|
|
|
|
vulnerabilities = filteredVulnerabilities;
|
|
hostConstraint = { in: hostIdsToFind };
|
|
report.total = vulnerabilities.length;
|
|
|
|
let startIndex = page * pageSize;
|
|
let endIndex = startIndex + pageSize;
|
|
let paginatedVulnerabilities = vulnerabilities.slice(startIndex, endIndex);
|
|
vulnerabilities = paginatedVulnerabilities;
|
|
}
|
|
|
|
// Build a where clause for the native query we will be sending.
|
|
let vulnerabilityInstallWhereClause;
|
|
|
|
// If we're including resolved install information in this report for a CSV export, we'll send a query without the uninstalledAt condition.
|
|
if(includeResolvedInstalls){
|
|
vulnerabilityInstallWhereClause = `WHERE vulnerability IN (${_.pluck(vulnerabilities,'id').join(',')})`;// Note this where clause will work with Postgres and MySQL datastores.
|
|
} else { // Otherwise, we'll send queries to only get unresolved installs.
|
|
|
|
if(sails.config.datastores.default.adapter === 'sails-postgresql') {
|
|
// If this app is configured to use a Postgres datastore, we'll need to put double quotes around the uninstalledAt column name.
|
|
vulnerabilityInstallWhereClause = `WHERE "uninstalledAt" = 0 AND vulnerability IN (${_.pluck(vulnerabilities,'id').join(',')})`;
|
|
} else if(sails.config.datastores.default.adapter === 'sails-mysql') {
|
|
vulnerabilityInstallWhereClause = `WHERE uninstalledAt = 0 AND vulnerability IN (${_.pluck(vulnerabilities,'id').join(',')})`;
|
|
}
|
|
}
|
|
|
|
// Build the native query to get affected installs for this vulnerability.
|
|
let vulnerabilityInstallNativeQuery = `
|
|
SELECT * FROM vulnerabilityinstall
|
|
${vulnerabilityInstallWhereClause}`;
|
|
|
|
let selectedInstallsFromEntireOrg = await sails.sendNativeQuery(vulnerabilityInstallNativeQuery);
|
|
|
|
let hostsFromEntireOrg = await Host.find();
|
|
|
|
for(let install of selectedInstallsFromEntireOrg.rows){
|
|
install.host = _.find(hostsFromEntireOrg, {id: install.host});
|
|
if(!install.host){
|
|
throw new Error(`Error: when mapping host records to VulnerabilityInstall records, a VulnerabilityInstall (id: ${install.id}) was found that is missing a "host" value! This is the database record that is missing a host: ${install}`);
|
|
}
|
|
}//∞
|
|
|
|
|
|
// Add numAffectedHosts, resolvedAt, affectedTeams, and affectedHosts to each vulnerability in the report.
|
|
await sails.helpers.flow.simultaneouslyForEach(vulnerabilities, async (vulnerability)=>{
|
|
let theseInstallsToReport = [];
|
|
if(teamApid === undefined) {
|
|
theseInstallsToReport = _.where(selectedInstallsFromEntireOrg.rows, {vulnerability: vulnerability.id});
|
|
} else {
|
|
theseInstallsToReport = _.where(selectedInstallsFromEntireOrg.rows, {vulnerability: vulnerability.id, host: { teamApid: teamApid } });
|
|
}
|
|
|
|
// Because the same vuln can apply to the same host multiple different times through different software versions
|
|
// this list of installs could contain the same host more than once. So when we grab the host IDs, we'll also deduplicate them to produce our count.
|
|
|
|
vulnerability.numAffectedHosts = _.uniq(_.pluck(theseInstallsToReport, 'host')).length;
|
|
|
|
// Get resolution timestamp for each vulnerability.
|
|
// (Or 0 if unresolved vulnerabilities are still installed on the hosts.)
|
|
if (theseInstallsToReport.length >= 1) {
|
|
|
|
// Note: if we're including resolved installs in theseInstallsToReport, this value will be set to 0 for each vulnerabiity,
|
|
// this is fine because we will tracking resolution for each vulnerable install, instead of the vulnerability.
|
|
vulnerability.resolvedAt = 0;
|
|
|
|
let affectedInstalls = [];
|
|
for (let install of theseInstallsToReport){
|
|
affectedInstalls.push({name: install.softwareName, version:install.versionName, fleetApid: install.fleetApid, url: sails.config.custom.fleetBaseUrl+'/software/'+install.fleetApid, affectedHost: install.host.id, installedAt: install.installedAt, uninstalledAt: install.uninstalledAt, resolvedInVersion: install.resolvedInVersion});
|
|
}
|
|
vulnerability.affectedInstalls = affectedInstalls;
|
|
vulnerability.affectedSoftware = _.uniq(affectedInstalls, 'fleetApid');
|
|
// Add Affected teams for vulnerabilities that have unresolved installs (or resolved installs
|
|
vulnerability.affectedTeams = _.uniq(_.pluck(theseInstallsToReport, 'host.teamDisplayName'));
|
|
} else {// Note: if we're including resolved installs in this report. (Note: this will only happen for CSV exports of all software items)
|
|
// Return the timestamp of the last uninstallation of this vuln amongst these hosts.
|
|
let recentResolvedInstalls = await VulnerabilityInstall.find({
|
|
uninstalledAt: { '!=': 0 },
|
|
host: hostConstraint,
|
|
vulnerability: vulnerability.id
|
|
})
|
|
.populate('host')
|
|
.sort('uninstalledAt DESC').limit(1);
|
|
if (!recentResolvedInstalls[0]) {
|
|
sails.log.warn(`Expected vulnerability ${vulnerability.cveId} (id: ${vulnerability.id}) with no unresolved installs to have a history of at least one resolved install. Otherwise, why is it here?`);
|
|
vulnerability.resolvedAt = 0;
|
|
} else {
|
|
vulnerability.resolvedAt = recentResolvedInstalls[0].uninstalledAt;
|
|
// If we're filtering by a team, we'll add the teamDisplayName to the affectedTeams array. otherwise, we'll send a blank value for this vulnerability.
|
|
// This is done to keep the results looking consistent when filtering by a team, while making sure we are still showing resolved vulnerabilities for this team to the user.
|
|
if(teamApid !== undefined) {
|
|
vulnerability.affectedTeams = [recentResolvedInstalls[0].host.teamDisplayName];
|
|
}
|
|
// Note: If we're not filtering vulnerabilities by a team, we won't send a affectedTeams value for resolved vulnerabilities. This will cause the column to appear as 'N/A' in the vulnerability list.
|
|
}
|
|
}//∞
|
|
|
|
// Add Affected hosts from live installs.
|
|
vulnerability.affectedHosts = _.uniq(_.pluck(theseInstallsToReport, 'host'));
|
|
});//∞
|
|
|
|
// If we're sorting by resolve date, then we'll sort the vulnerabilities after we've added a resolvedAt value.
|
|
if(sortResultsyByResolveDate){
|
|
if(sortDirection === 'ASC') {
|
|
vulnerabilities = _.sortBy(vulnerabilities, 'resolvedAt');
|
|
} else {
|
|
vulnerabilities = _(_.sortBy(vulnerabilities, 'resolvedAt')).reverse().value();
|
|
}
|
|
}
|
|
|
|
report.entries = vulnerabilities;
|
|
|
|
return report;
|
|
}
|
|
|
|
|
|
};
|
|
|