From bed640abefb04b90ee6390b9a030d36eb7145f95 Mon Sep 17 00:00:00 2001 From: maxjeffos <44034094+maxjeffos@users.noreply.github.com> Date: Thu, 25 Nov 2021 21:18:10 -0500 Subject: [PATCH] feat: snyk protect upgrade notification for test command --- packages/snyk-protect/README.md | 6 + src/cli/commands/test/index.ts | 27 +++ src/lib/protect-update-notification.ts | 116 ++++++++++++ src/lib/theme.ts | 1 + .../no-package-json/placeholder.txt | 1 + .../package-lock.json | 33 ++++ .../package.json | 11 ++ .../package-lock.json | 33 ++++ .../package.json | 12 ++ .../package-lock.json | 12 ++ .../package.json | 10 + .../protect-upgrade-notification.spec.ts | 174 ++++++++++++++++++ .../lib/protect-update-notification.spec.ts | 114 ++++++++++++ 13 files changed, 550 insertions(+) create mode 100644 src/lib/protect-update-notification.ts create mode 100644 test/fixtures/protect-update-notification/no-package-json/placeholder.txt create mode 100644 test/fixtures/protect-update-notification/with-package-json-with-snyk-dep-2/package-lock.json create mode 100644 test/fixtures/protect-update-notification/with-package-json-with-snyk-dep-2/package.json create mode 100644 test/fixtures/protect-update-notification/with-package-json-with-snyk-dep/package-lock.json create mode 100644 test/fixtures/protect-update-notification/with-package-json-with-snyk-dep/package.json create mode 100644 test/fixtures/protect-update-notification/with-package-json-without-snyk-dep/package-lock.json create mode 100644 test/fixtures/protect-update-notification/with-package-json-without-snyk-dep/package.json create mode 100644 test/jest/acceptance/snyk-test/protect-upgrade-notification.spec.ts create mode 100644 test/jest/unit/lib/protect-update-notification.spec.ts diff --git a/packages/snyk-protect/README.md b/packages/snyk-protect/README.md index f7df4e3827..529e8a118d 100644 --- a/packages/snyk-protect/README.md +++ b/packages/snyk-protect/README.md @@ -67,6 +67,12 @@ If you already have Snyk Protect set up, you can migrate to `@snyk/protect` by a } ``` +We have also created the [@snyk/cli-protect-upgrade](https://www.npmjs.com/package/@snyk/cli-protect-upgrade) npx script which you can use to update your project automatically. To use it, `cd` to the location containing the package.json to be updated and run: + +``` +npx @snyk/cli-protect-upgrade +``` + --- Made with 💜 by Snyk diff --git a/src/cli/commands/test/index.ts b/src/cli/commands/test/index.ts index e3edbbcb18..61aed2ad4b 100644 --- a/src/cli/commands/test/index.ts +++ b/src/cli/commands/test/index.ts @@ -1,4 +1,5 @@ import * as Debug from 'debug'; +import { EOL } from 'os'; const cloneDeep = require('lodash.clonedeep'); const assign = require('lodash.assign'); import chalk from 'chalk'; @@ -32,6 +33,12 @@ import { setDefaultTestOptions } from './set-default-test-options'; import { processCommandArgs } from '../process-command-args'; import { formatTestError } from './format-test-error'; import { displayResult } from '../../../lib/formatters/test/display-result'; +import * as analytics from '../../../lib/analytics'; + +import { + getPackageJsonPathsContainingSnykDependency, + getProtectUpgradeWarningForPaths, +} from '../../../lib/protect-update-notification'; const debug = Debug('snyk-test'); const SEPARATOR = '\n-------------------------------------------------------\n'; @@ -46,6 +53,16 @@ export default async function test( validateTestOptions(options); validateCredentials(options); + const packageJsonPathsWithSnykDepForProtect: string[] = getPackageJsonPathsContainingSnykDependency( + options.file, + paths, + ); + + analytics.add( + 'upgradable-snyk-protect-paths', + packageJsonPathsWithSnykDepForProtect.length, + ); + // Handles no image arg provided to the container command until // a validation interface is implemented in the docker plugin. if (options.docker && paths.length === 0) { @@ -255,6 +272,11 @@ export default async function test( if (!fail) { // return here to prevent throwing failure response += chalk.bold.green(summaryMessage); + response += EOL + EOL; + response += getProtectUpgradeWarningForPaths( + packageJsonPathsWithSnykDepForProtect, + ); + return TestCommandResult.createHumanReadableTestCommandResult( response, stringifiedJsonData, @@ -277,6 +299,11 @@ export default async function test( } response += chalk.bold.green(summaryMessage); + response += EOL + EOL; + response += getProtectUpgradeWarningForPaths( + packageJsonPathsWithSnykDepForProtect, + ); + return TestCommandResult.createHumanReadableTestCommandResult( response, stringifiedJsonData, diff --git a/src/lib/protect-update-notification.ts b/src/lib/protect-update-notification.ts new file mode 100644 index 0000000000..2cf1716393 --- /dev/null +++ b/src/lib/protect-update-notification.ts @@ -0,0 +1,116 @@ +import { EOL } from 'os'; +import * as theme from './theme'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as createDebug from 'debug'; + +const debug = createDebug('snyk-protect-update-notification'); + +export function getProtectUpgradeWarningForPaths( + packageJsonPaths: string[], +): string { + try { + if (packageJsonPaths?.length > 0) { + let message = theme.color.status.warn( + `${theme.icon.WARNING} WARNING: It looks like you have the \`snyk\` dependency in the \`package.json\` file(s) at the following path(s):` + + EOL, + ); + + packageJsonPaths.forEach((p) => { + message += theme.color.status.warn(` - ${p}` + EOL); + }); + + const githubReadmeUrlShort = 'https://snyk.co/ud1cR'; // https://github.com/snyk/snyk/tree/master/packages/snyk-protect#migrating-from-snyk-protect-to-snykprotect + + message += theme.color.status.warn( + `For more information and migration instructions, see ${githubReadmeUrlShort}` + + EOL, + ); + + return message; + } else { + return ''; + } + } catch (e) { + debug('Error in getProtectUpgradeWarningForPaths()', e); + return ''; + } +} + +export function packageJsonFileExistsInDirectory( + directoryPath: string, +): boolean { + try { + const packageJsonPath = path.resolve(directoryPath, 'package.json'); + const fileExists = fs.existsSync(packageJsonPath); + return fileExists; + } catch (e) { + debug('Error in packageJsonFileExistsInDirectory()', e); + return false; + } +} + +export function checkPackageJsonForSnykDependency( + packageJsonPath: string, +): boolean { + try { + const fileExists = fs.existsSync(packageJsonPath); + if (fileExists) { + const packageJson = fs.readFileSync(packageJsonPath, 'utf8'); + const packageJsonObject = JSON.parse(packageJson); + const snykDependency = packageJsonObject.dependencies['snyk']; + if (snykDependency) { + return true; + } + } + } catch (e) { + debug('Error in checkPackageJsonForSnykDependency()', e); + } + return false; +} + +export function getPackageJsonPathsContainingSnykDependency( + fileOption: string | undefined, + paths: string[], +): string[] { + const packageJsonPathsWithSnykDepForProtect: string[] = []; + + try { + if (fileOption) { + if ( + fileOption.endsWith('package.json') || + fileOption.endsWith('package-lock.json') + ) { + const directoryWithPackageJson = path.dirname(fileOption); + if (packageJsonFileExistsInDirectory(directoryWithPackageJson)) { + const packageJsonPath = path.resolve( + directoryWithPackageJson, + 'package.json', + ); + const packageJsonContainsSnykDep = checkPackageJsonForSnykDependency( + packageJsonPath, + ); + if (packageJsonContainsSnykDep) { + packageJsonPathsWithSnykDepForProtect.push(packageJsonPath); + } + } + } + } else { + paths.forEach((testPath) => { + if (packageJsonFileExistsInDirectory(testPath)) { + const packageJsonPath = path.resolve(testPath, 'package.json'); + const packageJsonContainsSnykDep = checkPackageJsonForSnykDependency( + packageJsonPath, + ); + if (packageJsonContainsSnykDep) { + packageJsonPathsWithSnykDepForProtect.push(packageJsonPath); + } + } + }); + } + } catch (e) { + debug('Error in getPackageJsonPathsContainingSnykDependency()', e); + } + + return packageJsonPathsWithSnykDepForProtect; +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 09b4c9a314..f7be58ea6d 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -11,6 +11,7 @@ export const icon = { export const color = { status: { error: (text: string) => chalk.red(text), + warn: (text: string) => chalk.yellow(text), success: (text: string) => chalk.green(text), }, severity: { diff --git a/test/fixtures/protect-update-notification/no-package-json/placeholder.txt b/test/fixtures/protect-update-notification/no-package-json/placeholder.txt new file mode 100644 index 0000000000..ce26825819 --- /dev/null +++ b/test/fixtures/protect-update-notification/no-package-json/placeholder.txt @@ -0,0 +1 @@ +placeholder file so that the `no-package-json` folder which contains this file exists for testing diff --git a/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep-2/package-lock.json b/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep-2/package-lock.json new file mode 100644 index 0000000000..32f15f325f --- /dev/null +++ b/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep-2/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "test", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "test", + "version": "1.0.0", + "dependencies": { + "snyk": "^1.773.0" + } + }, + "node_modules/snyk": { + "version": "1.778.0", + "resolved": "https://registry.npmjs.org/snyk/-/snyk-1.778.0.tgz", + "integrity": "sha512-jPC6OYKf4wc5GUyHzL0IyZTAEWE/sUHuOawEKFyoIECEwyPyEx+AYLjGYyloa0L++C2KZdYM5GXOaZzi0upUFA==", + "bin": { + "snyk": "bin/snyk" + }, + "engines": { + "node": ">=10" + } + } + }, + "dependencies": { + "snyk": { + "version": "1.778.0", + "resolved": "https://registry.npmjs.org/snyk/-/snyk-1.778.0.tgz", + "integrity": "sha512-jPC6OYKf4wc5GUyHzL0IyZTAEWE/sUHuOawEKFyoIECEwyPyEx+AYLjGYyloa0L++C2KZdYM5GXOaZzi0upUFA==" + } + } +} diff --git a/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep-2/package.json b/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep-2/package.json new file mode 100644 index 0000000000..bf1504d93e --- /dev/null +++ b/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep-2/package.json @@ -0,0 +1,11 @@ +{ + "name": "test", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "snyk": "^1.773.0" + } +} diff --git a/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep/package-lock.json b/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep/package-lock.json new file mode 100644 index 0000000000..32f15f325f --- /dev/null +++ b/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "test", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "test", + "version": "1.0.0", + "dependencies": { + "snyk": "^1.773.0" + } + }, + "node_modules/snyk": { + "version": "1.778.0", + "resolved": "https://registry.npmjs.org/snyk/-/snyk-1.778.0.tgz", + "integrity": "sha512-jPC6OYKf4wc5GUyHzL0IyZTAEWE/sUHuOawEKFyoIECEwyPyEx+AYLjGYyloa0L++C2KZdYM5GXOaZzi0upUFA==", + "bin": { + "snyk": "bin/snyk" + }, + "engines": { + "node": ">=10" + } + } + }, + "dependencies": { + "snyk": { + "version": "1.778.0", + "resolved": "https://registry.npmjs.org/snyk/-/snyk-1.778.0.tgz", + "integrity": "sha512-jPC6OYKf4wc5GUyHzL0IyZTAEWE/sUHuOawEKFyoIECEwyPyEx+AYLjGYyloa0L++C2KZdYM5GXOaZzi0upUFA==" + } + } +} diff --git a/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep/package.json b/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep/package.json new file mode 100644 index 0000000000..e2900ac1d1 --- /dev/null +++ b/test/fixtures/protect-update-notification/with-package-json-with-snyk-dep/package.json @@ -0,0 +1,12 @@ +{ + "name": "test", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "snyk": "^1.773.0" + } +} + diff --git a/test/fixtures/protect-update-notification/with-package-json-without-snyk-dep/package-lock.json b/test/fixtures/protect-update-notification/with-package-json-without-snyk-dep/package-lock.json new file mode 100644 index 0000000000..1ecf2a3560 --- /dev/null +++ b/test/fixtures/protect-update-notification/with-package-json-without-snyk-dep/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "test", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "test", + "version": "1.0.0" + } + } +} diff --git a/test/fixtures/protect-update-notification/with-package-json-without-snyk-dep/package.json b/test/fixtures/protect-update-notification/with-package-json-without-snyk-dep/package.json new file mode 100644 index 0000000000..8c5103494f --- /dev/null +++ b/test/fixtures/protect-update-notification/with-package-json-without-snyk-dep/package.json @@ -0,0 +1,10 @@ +{ + "name": "test", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + } +} diff --git a/test/jest/acceptance/snyk-test/protect-upgrade-notification.spec.ts b/test/jest/acceptance/snyk-test/protect-upgrade-notification.spec.ts new file mode 100644 index 0000000000..27297dee00 --- /dev/null +++ b/test/jest/acceptance/snyk-test/protect-upgrade-notification.spec.ts @@ -0,0 +1,174 @@ +import { fakeServer } from '../../../acceptance/fake-server'; +import { createProjectFromFixture } from '../../util/createProject'; +import { runSnykCLI } from '../../util/runSnykCLI'; + +jest.setTimeout(1000 * 30); + +describe('analytics module', () => { + let server; + let env: Record; + + beforeAll((done) => { + const port = process.env.PORT || process.env.SNYK_PORT || '12345'; + const baseApi = '/api/v1'; + env = { + ...process.env, + SNYK_API: 'http://localhost:' + port + baseApi, + SNYK_HOST: 'http://localhost:' + port, + SNYK_TOKEN: '123456789', + SNYK_INTEGRATION_NAME: 'JENKINS', + SNYK_INTEGRATION_VERSION: '1.2.3', + }; + server = fakeServer(baseApi, env.SNYK_TOKEN); + server.listen(port, () => { + done(); + }); + }); + + afterEach(() => { + server.restore(); + }); + + afterAll((done) => { + server.close(() => { + done(); + }); + }); + + it('detects upgradable protect paths with `snyk test` with upgradable path in the cwd', async () => { + const project = await createProjectFromFixture( + 'protect-update-notification/with-package-json-with-snyk-dep', + ); + + const { code, stdout } = await runSnykCLI('test', { + cwd: project.path(), + env, + }); + + expect(code).toBe(0); + expect(stdout).toContain( + 'WARNING: It looks like you have the `snyk` dependency in the `package.json` file(s) at the following path(s):', + ); + expect(stdout).toContain(project.path('package.json')); + + const lastRequest = server.popRequest(); + expect(lastRequest).toMatchObject({ + query: {}, + body: { + data: { + command: 'test', + metadata: { + 'upgradable-snyk-protect-paths': 1, + }, + }, + }, + }); + }); + + it('detects upgradable protect paths with `snyk test` using `--file=`', async () => { + const project = await createProjectFromFixture( + 'protect-update-notification/with-package-json-with-snyk-dep', + ); + + const pathToFile = project.path('package-lock.json'); + const { code, stdout } = await runSnykCLI(`test --file=${pathToFile}`, { + // note: not passing in the `cwd` of the project object + env, + }); + + expect(code).toBe(0); + expect(stdout).toContain( + 'WARNING: It looks like you have the `snyk` dependency in the `package.json` file(s) at the following path(s):', + ); + expect(stdout).toContain(project.path('package.json')); + + const lastRequest = server.popRequest(); + expect(lastRequest).toMatchObject({ + query: {}, + body: { + data: { + command: 'test', + metadata: { + 'upgradable-snyk-protect-paths': 1, + }, + }, + }, + }); + }); + + it('detects upgradable protect paths with `snyk test` using paths as positional args', async () => { + const project = await createProjectFromFixture( + 'protect-update-notification', + ); + + const paths = [ + project.path('with-package-json-with-snyk-dep'), + project.path('with-package-json-with-snyk-dep-2'), + project.path('with-package-json-without-snyk-dep'), + ]; + + const pathsStr = paths.join(' '); + + const { code, stdout } = await runSnykCLI(`test ${pathsStr}`, { + // note: not passing in the `cwd` of the project object + env, + }); + + expect(code).toBe(0); + expect(stdout).toContain( + 'WARNING: It looks like you have the `snyk` dependency in the `package.json` file(s) at the following path(s):', + ); + expect(stdout).toContain( + project.path('with-package-json-with-snyk-dep/package.json'), + ); + expect(stdout).toContain( + project.path('with-package-json-with-snyk-dep-2/package.json'), + ); + expect(stdout).not.toContain( + project.path('with-package-json-without-snyk-dep/package.json'), + ); + + const lastRequest = server.popRequest(); + expect(lastRequest).toMatchObject({ + query: {}, + body: { + data: { + command: 'test', + metadata: { + 'upgradable-snyk-protect-paths': 2, + }, + }, + }, + }); + }); + + it('detects no upgradable protect paths with `snyk test` with no upgradable paths in the cwd', async () => { + const project = await createProjectFromFixture( + 'protect-update-notification/with-package-json-without-snyk-dep', + ); + + const { code, stdout } = await runSnykCLI('test', { + cwd: project.path(), + env, + }); + + expect(code).toBe(0); + expect(stdout).not.toContain( + 'WARNING: It looks like you have the `snyk` dependency in the `package.json` file(s) at the following path(s):', + ); + expect(stdout).not.toContain(project.path('package.json')); + + const lastRequest = server.popRequest(); + expect(lastRequest).toMatchObject({ + query: {}, + body: { + data: { + command: 'test', + metadata: { + 'upgradable-snyk-protect-paths': 0, + }, + }, + }, + }); + }); +}); diff --git a/test/jest/unit/lib/protect-update-notification.spec.ts b/test/jest/unit/lib/protect-update-notification.spec.ts new file mode 100644 index 0000000000..0f9ad1dc34 --- /dev/null +++ b/test/jest/unit/lib/protect-update-notification.spec.ts @@ -0,0 +1,114 @@ +import * as path from 'path'; +import * as pun from '../../../../src/lib/protect-update-notification'; + +describe('getPackageJsonPathsContainingSnykDependency', () => { + describe('with --file option used', () => { + it('returns empty array when the given path does end with `package.json` or `package-lock.json`', () => { + expect( + pun.getPackageJsonPathsContainingSnykDependency('/path/to/pom.xml', [ + '/dont-care', + ]), + ).toEqual([]); + }); + + it('returns empty array when the given path ends with `package.json` but the file does not actually exit', () => { + expect( + pun.getPackageJsonPathsContainingSnykDependency( + '/path/to/package.json', + ['/dont-care'], + ), + ).toEqual([]); + }); + + it('returns empty array when the given path ends with `package-lock.json` but the file does not actually exit', () => { + const p = path.resolve( + __dirname, + '../../../fixtures/protect-update-notification/no-package-json/package.json', + ); + expect( + pun.getPackageJsonPathsContainingSnykDependency(p, ['/dont-care']), + ).toEqual([]); + }); + + it('returns an array with a path to a package.json if the file passed exists and contains the `snyk` dependency', () => { + const p = path.resolve( + __dirname, + '../../../fixtures/protect-update-notification/with-package-json-with-snyk-dep/package.json', + ); + expect( + pun.getPackageJsonPathsContainingSnykDependency(p, ['/dont-care']), + ).toEqual([p]); + }); + }); + + describe('no --file option used', () => { + it('returns empty list when no paths are passed', () => { + expect( + pun.getPackageJsonPathsContainingSnykDependency(undefined, []), + ).toEqual([]); + }); + + describe('single path passed', () => { + it('returns an empty array if no package.json is found in the given directory path', () => { + const p = path.resolve( + __dirname, + '../../../fixtures/protect-update-notification/no-package-json', + ); + expect( + pun.getPackageJsonPathsContainingSnykDependency(undefined, [p]), + ).toEqual([]); + }); + + it('returns an empty array if no package.json is found in the given directory path', () => { + const p = path.resolve( + __dirname, + '../../../fixtures/protect-update-notification/with-package-json-without-snyk-dep', + ); + expect( + pun.getPackageJsonPathsContainingSnykDependency(undefined, [p]), + ).toEqual([]); + }); + + it('returns a path to a package.json if one is found in the given directory path', () => { + const p = path.resolve( + __dirname, + '../../../fixtures/protect-update-notification/with-package-json-with-snyk-dep', + ); + expect( + pun.getPackageJsonPathsContainingSnykDependency(undefined, [p]), + ).toEqual([path.resolve(p, 'package.json')]); + }); + }); + + describe('with multiple paths passed', () => { + it('returns an array containing only those paths which have `package.json` with the `snyk` dep', () => { + const basePath = path.resolve( + __dirname, + '../../../fixtures/protect-update-notification', + ); + + const paths = [ + path.resolve(basePath, 'no-package-json'), + path.resolve(basePath, 'with-package-json-with-snyk-dep'), + path.resolve(basePath, 'with-package-json-with-snyk-dep-2'), + path.resolve(basePath, 'with-package-json-without-snyk-dep'), + ]; + + expect( + pun.getPackageJsonPathsContainingSnykDependency(undefined, paths), + ).toEqual( + expect.arrayContaining([ + path.resolve( + basePath, + 'with-package-json-with-snyk-dep/package.json', + ), + path.resolve( + basePath, + 'with-package-json-with-snyk-dep-2/package.json', + ), + ]), + ); + }); + }); + }); +});