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