fleet/ee/vulnerability-dashboard/api/helpers/get-vulnerabilities.js

308 lines
12 KiB
JavaScript
Raw Normal View History

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;
}
};