Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] pinning remediation advice for Python #737

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 1 addition & 20 deletions src/cli/commands/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import chalk from 'chalk';
import * as pathUtil from 'path';
import * as spinner from '../../lib/spinner';

import request = require('../../lib/request');
import * as detect from '../../lib/detect';
import * as plugins from '../../lib/plugins';
import {ModuleInfo} from '../../lib/module-info'; // TODO(kyegupov): fix import
Expand All @@ -28,14 +27,10 @@ import {
UnsupportedFeatureFlagError,
} from '../../lib/errors';
import { legacyPlugin as pluginApi } from '@snyk/cli-interface';
import { isFeatureFlagSupportedForOrg } from '../../lib/feature-flags';

const SEPARATOR = '\n-------------------------------------------------------\n';

interface OrgFeatureFlagResponse {
ok: boolean;
userMessage?: string;
}

interface GoodResult {
ok: true;
data: string;
Expand Down Expand Up @@ -292,17 +287,3 @@ function formatMonitorOutput(
packageManager,
})) : strOutput;
}

async function isFeatureFlagSupportedForOrg(featureFlag: string): Promise<OrgFeatureFlagResponse> {
const response = await request({
method: 'GET',
headers: {
Authorization: `token ${snyk.api}`,
},
url: `${config.API}/cli-config/feature-flags/${featureFlag}`,
gzip: true,
json: true,
});

return (response as any).body;
}
27 changes: 22 additions & 5 deletions src/cli/commands/test/formatters/legacy-format-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import chalk from 'chalk';
import * as config from '../../../../lib/config';
import {Options, TestOptions} from '../../../../lib/types';
import {isLocalFolder} from '../../../../lib/detect';
import { WIZARD_SUPPORTED_PACKAGE_MANAGERS } from '../../../../lib/package-managers';
import {
WIZARD_SUPPORTED_PACKAGE_MANAGERS,
SupportedPackageManagers,
PINNING_SUPPORTED_PACKAGE_MANAGERS } from '../../../../lib/package-managers';
import { GroupedVuln } from '../../../../lib/snyk-test/legacy';
import parsePackageNameVersion = require('snyk-module');

export function formatIssues(vuln: GroupedVuln, options: Options & TestOptions) {
const vulnID = vuln.list[0].id;
Expand Down Expand Up @@ -38,7 +42,7 @@ export function formatIssues(vuln: GroupedVuln, options: Options & TestOptions)
? createTruncatedVulnsPathsText(vuln.list) : '',
extraInfo: vuln.note ? chalk.bold('\n Note: ' + vuln.note) : '',
remediationInfo: vuln.metadata.type !== 'license' && localPackageTest
? createRemediationText(vuln, packageManager)
? createRemediationText(vuln, packageManager, !!options.pinningSupported)
: '',
fixedIn: options.docker ? createFixedInText(vuln) : '',
dockerfilePackage: options.docker ? dockerfileInstructionText(vuln) : '',
Expand Down Expand Up @@ -135,12 +139,25 @@ function createFixedInText(vuln: any): string {
return '';
}

function createRemediationText(vuln, packageManager) {
function createRemediationText(
vuln: GroupedVuln,
packageManager: SupportedPackageManagers,
pinningSupported: boolean,
): string {
let wizardHintText = '';
if (WIZARD_SUPPORTED_PACKAGE_MANAGERS.includes(packageManager)) {
wizardHintText = 'Run `snyk wizard` to explore remediation options.';
}

if (pinningSupported
&& (vuln.nearestFixedInVersion || vuln.fixedIn)
&& PINNING_SUPPORTED_PACKAGE_MANAGERS.includes(packageManager)
) {
const toVersion = vuln.nearestFixedInVersion || vuln.fixedIn.join(' or ');
const transitive = vuln.list.every((i) => i.from.length > 2);

const action = transitive ? 'Pin the transitive' : 'Update the';
return chalk.bold(`\n Remediation: \n ${action} dependency ${vuln.name} to version ${toVersion}`);
}
if (vuln.isFixable === true) {
const upgradePathsArray = _.uniq(vuln.list.map((v) => {
const shouldUpgradeItself = !!v.upgradePath[0];
Expand All @@ -153,7 +170,7 @@ function createRemediationText(vuln, packageManager) {
const selfUpgradeInfo = (v.upgradePath.length > 0)
? ` (triggers upgrades to ${ v.upgradePath.join(' > ')})`
: '';
const testedPackageName = v.upgradePath[0].split('@');
const testedPackageName = parsePackageNameVersion(v.upgradePath[0] as string);
return `You've tested an outdated version of ${testedPackageName[0]}.` +
+ ` Upgrade to ${v.upgradePath[0]}${selfUpgradeInfo}`;
}
Expand Down
172 changes: 146 additions & 26 deletions src/cli/commands/test/formatters/remediation-based-format-issues.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as _ from 'lodash';
import chalk from 'chalk';
import * as config from '../../../../lib/config';
import { TestOptions } from '../../../../lib/types';
import { RemediationResult, PatchRemediation,
DependencyUpdates, IssueData, SEVERITY, GroupedVuln } from '../../../../lib/snyk-test/legacy';
import {
RemediationChanges, PatchRemediation,
DependencyUpdates, IssueData, SEVERITY, GroupedVuln,
DependencyPins,
} from '../../../../lib/snyk-test/legacy';

interface BasicVulnInfo {
title: string;
Expand All @@ -12,13 +14,23 @@ interface BasicVulnInfo {
name: string;
version: string;
fixedIn: string[];
paths: string[][];
}

interface TopLevelPackageUpgrade {
name: string;
version: string;
}

interface UpgradesByCulprit {
[culpritNameAndVersion: string]: TopLevelPackageUpgrade[];
}

export function formatIssuesWithRemediation(
vulns: GroupedVuln[],
remediationInfo: RemediationResult,
remediationInfo: RemediationChanges,
options: TestOptions,
): string[] {
): string[] {

const basicVulnInfo: {
[name: string]: BasicVulnInfo,
Expand All @@ -32,13 +44,37 @@ export function formatIssuesWithRemediation(
name: vuln.name,
version: vuln.version,
fixedIn: vuln.fixedIn,
paths: vuln.list.map((v) => v.from),
};
}
const results = [chalk.bold.white('Remediation advice')];

const upgradeTextArray = constructUpgradesText(remediationInfo.upgrade, basicVulnInfo);
if (upgradeTextArray.length > 0) {
results.push(upgradeTextArray.join('\n'));
if (remediationInfo.pin && Object.keys(remediationInfo.pin).length) {
const upgragesByCulprit: UpgradesByCulprit = {};
for (const topLvlPkg of Object.keys(remediationInfo.upgrade)) {
for (const targetPkgStr of remediationInfo.upgrade[topLvlPkg].upgrades) {
if (!upgragesByCulprit[targetPkgStr]) {
upgragesByCulprit[targetPkgStr] = [];
}
upgragesByCulprit[targetPkgStr].push({
name: topLvlPkg,
version: remediationInfo.upgrade[topLvlPkg].upgradeTo,
});
}
}
const upgradeTextArray = constructPinOrUpgradesText(remediationInfo.pin, upgragesByCulprit, basicVulnInfo);
if (upgradeTextArray.length > 0) {
results.push(upgradeTextArray.join('\n'));
}
const allVulnIds = new Set();
Object.keys(remediationInfo.pin).forEach(
(name) => remediationInfo.pin[name].issues.forEach((vid) => allVulnIds.add(vid)));
remediationInfo.unresolved = remediationInfo.unresolved.filter((issue) => !allVulnIds.has(issue.id));
} else {
const upgradeTextArray = constructUpgradesText(remediationInfo.upgrade, basicVulnInfo);
if (upgradeTextArray.length > 0) {
results.push(upgradeTextArray.join('\n'));
}
}

const patchedTextArray = constructPatchesText(remediationInfo.patch, basicVulnInfo);
Expand All @@ -63,7 +99,7 @@ function constructPatchesText(
basicVulnInfo: {
[name: string]: BasicVulnInfo;
},
): string[] {
): string[] {

if (!(Object.keys(patches).length > 0)) {
return [];
Expand All @@ -74,12 +110,12 @@ function constructPatchesText(
const packageAtVersion = `${basicVulnInfo[id].name}@${basicVulnInfo[id].version}`;
const patchedText = `\n Patch available for ${chalk.bold.whiteBright(packageAtVersion)}\n`;
const thisPatchFixes =
formatIssue(
id,
basicVulnInfo[id].title,
basicVulnInfo[id].severity,
basicVulnInfo[id].isNew,
`${basicVulnInfo[id].name}@${basicVulnInfo[id].version}`);
formatIssue(
id,
basicVulnInfo[id].title,
basicVulnInfo[id].severity,
basicVulnInfo[id].isNew,
`${basicVulnInfo[id].name}@${basicVulnInfo[id].version}`);
patchedTextArray.push(patchedText + thisPatchFixes);
}

Expand All @@ -91,31 +127,115 @@ function constructUpgradesText(
basicVulnInfo: {
[name: string]: BasicVulnInfo;
},
): string[] {
): string[] {

if (!(Object.keys(upgrades).length > 0)) {
return [];
}

const upgradeTextArray = [chalk.bold.green('\nUpgradable Issues:')];
const upgradeTextArray = [chalk.bold.green('\nIssues to fix by upgrading:')];
for (const upgrade of Object.keys(upgrades)) {
const upgradeDepTo = _.get(upgrades, [upgrade, 'upgradeTo']);
const vulnIds = _.get(upgrades, [upgrade, 'vulns']);
const data = upgrades[upgrade];
const upgradeDepTo = data.upgradeTo;
const vulnIds = data.vulns;
const upgradeText =
`\n Upgrade ${chalk.bold.whiteBright(upgrade)} to ${chalk.bold.whiteBright(upgradeDepTo)} to fix\n`;
`\n Upgrade ${chalk.bold.whiteBright(upgrade)} to ${chalk.bold.whiteBright(upgradeDepTo)} to fix\n`;
const thisUpgradeFixes = vulnIds
.map((id) => formatIssue(
id,
basicVulnInfo[id].title,
basicVulnInfo[id].severity,
basicVulnInfo[id].isNew,
`${basicVulnInfo[id].name}@${basicVulnInfo[id].version}`))
id,
basicVulnInfo[id].title,
basicVulnInfo[id].severity,
basicVulnInfo[id].isNew,
`${basicVulnInfo[id].name}@${basicVulnInfo[id].version}`))
.join('\n');
upgradeTextArray.push(upgradeText + thisUpgradeFixes);
}
return upgradeTextArray;
}

function constructPinOrUpgradesText(
pins: DependencyPins,
upgradesByCulprit: UpgradesByCulprit,
basicVulnInfo: {
[name: string]: BasicVulnInfo;
},
): string[] {

if (!(Object.keys(pins).length)) {
return [];
}

const thisUpgradeFixes = (vulnIds: string[]) => (
vulnIds.map((id) => formatIssue(
id,
basicVulnInfo[id].title,
basicVulnInfo[id].severity,
basicVulnInfo[id].isNew,
`${basicVulnInfo[id].name}@${basicVulnInfo[id].version}`))
.join('\n')
);

// First, direct upgrades
const upgradeTextArray: string[] = [];

const upgradeables = Object.keys(pins).filter((name) => !pins[name].isTransitive);
if (upgradeables.length) {
upgradeTextArray.push(chalk.bold.green('\nIssues to fix by upgrading existing dependencies:'));

for (const pin of upgradeables) {
const data = pins[pin];
const vulnIds = data.issues;
const upgradeDepTo = data.upgradeTo;
const upgradeText =
`\n Upgrade ${chalk.bold.whiteBright(pin)} to ${chalk.bold.whiteBright(upgradeDepTo)} to fix\n`;
upgradeTextArray.push(upgradeText);
upgradeTextArray.push(thisUpgradeFixes(vulnIds));
}
}

// Second, pins
const pinables = Object.keys(pins).filter((name) => pins[name].isTransitive);

if (pinables.length) {
upgradeTextArray.push(chalk.bold.green('\nIssues to fix by pinning sub-dependencies:'));

for (const pin of pinables) {
const data = pins[pin];
const vulnIds = data.issues;
const upgradeDepTo = data.upgradeTo;
const upgradeText =
`\n Pin ${chalk.bold.whiteBright(pin)} to ${chalk.bold.whiteBright(upgradeDepTo)} to fix\n`;
upgradeTextArray.push(upgradeText);
upgradeTextArray.push(thisUpgradeFixes(vulnIds));
const allPaths = new Set();
for (const vid of vulnIds) {
for (const path of basicVulnInfo[vid].paths) {
allPaths.add(path.slice(1).join(' > '));
}
}
upgradeTextArray.push(allPaths.size === 1
? ` (introduced by ${allPaths.keys().next().value})`
: ` (introduced by ${allPaths.keys().next().value} and ${allPaths.size - 1} other path(s))`);
const topLevelUpgradesSet = new Set();
for (const vid of vulnIds) {
const maybeTopLevelUpgrades = upgradesByCulprit[pin + '@' + basicVulnInfo[vid].version];
if (maybeTopLevelUpgrades) {
for (const topLvlPkg of maybeTopLevelUpgrades) {
const setKey = `${topLvlPkg.name}\n${topLvlPkg.version}`;
if (!topLevelUpgradesSet.has(setKey)) {
topLevelUpgradesSet.add(setKey);
upgradeTextArray.push(' The issues above can also be fixed by upgrading top-level dependency ' +
`${topLvlPkg.name} to ${topLvlPkg.version}`);
}
}
}
}
}
}

return upgradeTextArray;
}

function constructUnfixableText(unresolved: IssueData[]) {
if (!(unresolved.length > 0)) {
return [];
Expand Down Expand Up @@ -161,7 +281,7 @@ function formatIssue(

return severitiesColourMapping[severity].colorFunc(
` ✗ ${chalk.bold(title)}${newBadge} [${titleCaseText(severity)} Severity]`,
) + `[${config.ROOT}/vuln/${id}]` + name;
) + `[${config.ROOT}/vuln/${id}]` + name;
}

function titleCaseText(text) {
Expand Down
12 changes: 10 additions & 2 deletions src/cli/commands/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { LegacyVulnApiResult, SEVERITY, GroupedVuln, VulnMetaData } from '../../
import { formatIssues } from './formatters/legacy-format-issue';
import { WIZARD_SUPPORTED_PACKAGE_MANAGERS } from '../../../lib/package-managers';
import { formatIssuesWithRemediation } from './formatters/remediation-based-format-issues';
import { isFeatureFlagSupportedForOrg } from '../../../lib/feature-flags';

const debug = Debug('snyk');
const SEPARATOR = '\n-------------------------------------------------------\n';
Expand Down Expand Up @@ -142,8 +143,15 @@ async function test(...args: MethodArgs): Promise<string> {
throw err;
}

const pinningSupported =
results.find((r) => (r as LegacyVulnApiResult).packageManager === 'pip') &&
(await isFeatureFlagSupportedForOrg('pythonPinning')).ok;

let response = results
.map((unused, i) => displayResult(results[i] as LegacyVulnApiResult, resultOptions[i]))
.map((unused, i) => {
resultOptions[i].pinningSupported = pinningSupported;
return displayResult(results[i] as LegacyVulnApiResult, resultOptions[i]);
})
.join(`\n${SEPARATOR}`);

if (notSuccess) {
Expand Down Expand Up @@ -217,7 +225,7 @@ function summariseErrorResults(errorResults) {
return '';
}

function displayResult(res, options: Options & TestOptions) {
function displayResult(res: LegacyVulnApiResult, options: Options & TestOptions) {
const meta = metaForDisplay(res, options) + '\n\n';
const dockerAdvice = dockerRemediationForDisplay(res);
const packageManager = options.packageManager;
Expand Down
Loading