From 3e7e99eca8f02e87597987e3c0bde3942dfc6ff0 Mon Sep 17 00:00:00 2001 From: maxjeffos <44034094+maxjeffos@users.noreply.github.com> Date: Fri, 19 Feb 2021 17:00:55 -0500 Subject: [PATCH] feat: implement snyk protect --- packages/snyk-protect/src/index.ts | 220 +-------------- .../src/lib/explore-node-modules.ts | 66 +++++ packages/snyk-protect/src/lib/get-patches.ts | 94 +++++++ packages/snyk-protect/src/lib/index.ts | 54 +++- packages/snyk-protect/src/lib/patch.ts | 73 +++++ packages/snyk-protect/src/lib/snyk-file.ts | 41 +++ packages/snyk-protect/src/lib/types.ts | 19 ++ .../protect-acceptance-tests.spec.ts | 40 +++ .../multiple-matching-paths/package-lock.json | 2 +- .../multiple-matching-paths/package.json | 2 +- .../no-matching-paths/package-lock.json | 2 +- .../fixtures/no-matching-paths/package.json | 2 +- .../single-patchable-module/package-lock.json | 2 +- .../single-patchable-module/package.json | 2 +- .../test/unit/protect-unit-tests.spec.ts | 254 ++++++++++++++++++ 15 files changed, 652 insertions(+), 221 deletions(-) create mode 100644 packages/snyk-protect/src/lib/explore-node-modules.ts create mode 100644 packages/snyk-protect/src/lib/get-patches.ts create mode 100644 packages/snyk-protect/src/lib/patch.ts create mode 100644 packages/snyk-protect/src/lib/snyk-file.ts create mode 100644 packages/snyk-protect/src/lib/types.ts create mode 100644 packages/snyk-protect/test/acceptance/protect-acceptance-tests.spec.ts create mode 100644 packages/snyk-protect/test/unit/protect-unit-tests.spec.ts diff --git a/packages/snyk-protect/src/index.ts b/packages/snyk-protect/src/index.ts index a925cdf43b..7c017edb86 100644 --- a/packages/snyk-protect/src/index.ts +++ b/packages/snyk-protect/src/index.ts @@ -1,218 +1,12 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as https from 'https'; +#!/usr/bin/env node -async function protect() { - let writingPatches = false; - let writingTo: string; +import protect from './lib'; - // .snyk parsing => snyk-policy ( or js-yaml ) - const patches = fs - .readFileSync('.snyk', 'utf8') - .split('\n') - .filter((l) => l.length && !l.trimStart().startsWith('#')) - .map(/^(\s*)(.*):(?:$| )+(.*)$/i.exec) - .filter(Boolean) - .reduce((acc, thing) => { - const [, prefix, key, value] = thing as RegExpExecArray; - if (writingPatches && prefix === '') { - writingPatches = false; - } else if (prefix === '' && key === 'patch' && value === '') { - writingPatches = true; - } else if (writingPatches) { - if (prefix.length === 2) { - writingTo = key; - acc[key] = []; - } else { - if (key.startsWith('-')) { - const destination = key - .split('>') - .pop() - ?.trim(); - if (!acc[writingTo].includes(destination)) { - acc[writingTo].push(destination); - } - } - } - } - return acc; - }, {}); - - const librariesOfInterest = Object.values(patches).flat(); - const patchesOfInterest = Object.keys(patches); - const foundLibraries: any[] = []; - - // parse node_modules - function isDependencyToBePatched(folderName, folderPath) { - if (!librariesOfInterest.includes(folderName)) { - return false; - } - - const packageJsonPath = path.resolve(folderPath, 'package.json'); - if ( - fs.existsSync(packageJsonPath) && - fs.lstatSync(packageJsonPath).isFile() - ) { - const { name, version } = JSON.parse( - fs.readFileSync(packageJsonPath, 'utf8'), - ); - if (librariesOfInterest.includes(name)) { - foundLibraries.push({ - name, - version, - folderPath, - }); - } - } - } - function checkProject(pathToCheck) { - if (fs.existsSync(pathToCheck) && fs.lstatSync(pathToCheck).isDirectory()) { - const folderName = path.basename(pathToCheck); - isDependencyToBePatched(folderName, pathToCheck); - const folderNodeModules = path.resolve(pathToCheck, 'node_modules'); - if ( - fs.existsSync(folderNodeModules) && - fs.lstatSync(folderNodeModules).isDirectory() - ) { - fs.readdirSync(folderNodeModules).forEach((p) => { - checkProject(path.resolve(folderNodeModules, p)); - }); - } - } - } - checkProject(path.resolve(__dirname, '.')); - - // fetch patches => needle - const httpsGet = (url: string, options: any = {}): Promise => - new Promise((resolve, reject) => { - const parsedURL = new URL(url); - const requestOptions = { - ...options, - host: parsedURL.host, - path: parsedURL.pathname, - }; - const request = https.get(requestOptions, (response) => { - if ( - response.statusCode && - (response.statusCode < 200 || response.statusCode > 299) - ) { - reject( - new Error( - 'Failed to load page, status code: ' + response.statusCode, - ), - ); - } - const body: any[] = []; - response.on('data', (chunk: any) => body.push(chunk)); - response.on('end', () => - resolve(options.json ? JSON.parse(body.join('')) : body.join('')), - ); - }); - request.on('error', reject); - }); - - async function getPatches() { - const snykPatches = {}; - const checkedLibraries: any[] = []; - for (const foundLibrary of foundLibraries) { - const toCheck = `${foundLibrary.name}/${foundLibrary.version}`; - if (!checkedLibraries.includes(toCheck)) { - checkedLibraries.push(toCheck); - const { issues } = await httpsGet( - `https://snyk.io/api/v1/test/npm/${toCheck}`, - { - json: true, - headers: { - Authorization: `token ${process.env.SNYK_TOKEN}`, - 'Content-Type': 'application/json', - }, - }, - ); - if (issues.vulnerabilities) { - for (const vulnerability of issues.vulnerabilities) { - if (patchesOfInterest.includes(vulnerability.id)) { - snykPatches[vulnerability.package] = - snykPatches[vulnerability.package] || []; - const fetchedPatches = await Promise.all( - vulnerability.patches.map(async (patch) => { - return { - ...patch, - diffs: await Promise.all( - patch.urls.map(async (url) => httpsGet(url)), - ), - }; - }), - ); - snykPatches[vulnerability.package] = [...fetchedPatches]; - } - } - } - } - } - return snykPatches; - } - - const snykPatches = await getPatches(); - if (Object.keys(snykPatches).length === 0) { - console.log('Nothing to patch, done'); - return; - } - - for (const [libToPatch, patches] of Object.entries(snykPatches)) { - for (const place of foundLibraries.filter((l) => l.name === libToPatch)) { - for (const patch of patches as any) { - for (const patchDiff of (patch as any).diffs) { - applyDiff(patchDiff, place.folderPath); - } - } - } - } +async function main() { + const projectPath = process.cwd(); + await protect(projectPath); } -// apply patches => patch apply || git apply || js-diff -function applyDiff(patchDiff: string, baseFolder: string) { - const patchFile = patchDiff.slice(patchDiff.search(/^--- a\//m)).split('\n'); - const filename = path.resolve(baseFolder, patchFile[0].replace('--- a/', '')); - - const fileToPatch = fs.readFileSync(filename, 'utf-8').split('\n'); - if (!patchFile[2]) { - return; - } - const unparsedLineToPatch = /^@@ -(\d*),.*@@/.exec(patchFile[2]); - if (!unparsedLineToPatch || !unparsedLineToPatch[1]) { - return; - } - let lineToPatch = parseInt(unparsedLineToPatch[1], 10) - 2; - - const patchLines = patchFile.slice(3, patchFile.length - 2); - - for (const patchLine of patchLines) { - lineToPatch += 1; - switch (patchLine.charAt(0)) { - case '-': - fileToPatch.splice(lineToPatch, 1); - break; - - case '+': - fileToPatch.splice(lineToPatch, 0, patchLine); - break; - - case ' ': - if (fileToPatch[lineToPatch] !== patchLine.slice(1)) { - console.log( - 'Expected\n line from local file\n', - fileToPatch[lineToPatch], - ); - console.log('\n to match patch line\n', patchLine.slice(1), '\n'); - throw new Error( - `File ${filename} to be patched does not match, not patching`, - ); - } - break; - } - } - - // fs.writeFileSync(filename, patchLines.join('\n')) +if (require.main === module) { + main(); } - -export default protect; diff --git a/packages/snyk-protect/src/lib/explore-node-modules.ts b/packages/snyk-protect/src/lib/explore-node-modules.ts new file mode 100644 index 0000000000..0e720cae75 --- /dev/null +++ b/packages/snyk-protect/src/lib/explore-node-modules.ts @@ -0,0 +1,66 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { PhysicalModuleToPatch } from './types'; + +// Check if a physical module (given by folderPath) is a thing we want to patch and if it is, add it to the list of modules to patch +// for a given path and +function checkPhysicalModule( + folderPath: string, + librariesOfInterest: Readonly, + physicalModulesToPatch: PhysicalModuleToPatch[], +) { + const folderName = path.basename(folderPath); + if (!librariesOfInterest.includes(folderName)) { + return false; + } + + const packageJsonPath = path.resolve(folderPath, 'package.json'); + if ( + fs.existsSync(packageJsonPath) && + fs.lstatSync(packageJsonPath).isFile() + ) { + const { name, version } = JSON.parse( + fs.readFileSync(packageJsonPath, 'utf8'), + ); + if (librariesOfInterest.includes(name)) { + physicalModulesToPatch.push({ + name, + version, + folderPath, + } as PhysicalModuleToPatch); + } + } +} + +// splelunk down the node_modules folder of a project given the project root directory looking for +// physical modules which match our librariesOfInterest +// we do not check for matching version at this point (that happens in getPatches) +// calls checkPhysicalModule +// calls itself recursively +export function checkProject( + pathToCheck: string, + librariesOfInterest: Readonly, + physicalModulesToPatch: PhysicalModuleToPatch[], +) { + if (fs.existsSync(pathToCheck) && fs.lstatSync(pathToCheck).isDirectory()) { + checkPhysicalModule( + pathToCheck, + librariesOfInterest, + physicalModulesToPatch, + ); + + const folderNodeModules = path.resolve(pathToCheck, 'node_modules'); + if ( + fs.existsSync(folderNodeModules) && + fs.lstatSync(folderNodeModules).isDirectory() + ) { + fs.readdirSync(folderNodeModules).forEach((p) => { + checkProject( + path.resolve(folderNodeModules, p), + librariesOfInterest, + physicalModulesToPatch, + ); + }); + } + } +} diff --git a/packages/snyk-protect/src/lib/get-patches.ts b/packages/snyk-protect/src/lib/get-patches.ts new file mode 100644 index 0000000000..e1f63cf1a6 --- /dev/null +++ b/packages/snyk-protect/src/lib/get-patches.ts @@ -0,0 +1,94 @@ +import * as https from 'https'; +import { PackageAndVersion } from './types'; + +export async function getPatches( + foundPackages: PackageAndVersion[], + patchesOfInterest: string[], +) { + const snykPatches = {}; + const checkedLibraries: any[] = []; + for (const foundLibrary of foundPackages) { + const toCheck = `${foundLibrary.name}/${foundLibrary.version}`; + if (!checkedLibraries.includes(toCheck)) { + checkedLibraries.push(toCheck); + + const snykToken = process.env.SNYK_TOKEN || process.env.SNYK_API_KEY; + if (!snykToken) { + throw new Error('SNYK_TOKEN must be set'); + } + + let apiBaseUrl = 'https://snyk.io/api'; + if (process.env.SNYK_API) { + if (process.env.SNYK_API.endsWith('/api')) { + apiBaseUrl = process.env.SNYK_API; + } else if (process.env.SNYK_API.endsWith('/api/v1')) { + // snyk CI environment - we use `.../api/v1` though the norm is just `.../api` + apiBaseUrl = process.env.SNYK_API.replace('/v1', ''); + } else { + console.log( + 'Malformed SNYK_API value. Using default: https://snyk.io/api', + ); + } + } + + const { issues } = await httpsGet( + `${apiBaseUrl}/v1/test/npm/${toCheck}`, + { + json: true, + headers: { + // TODO: remove after replacing with new API endpoint + Authorization: `token ${snykToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + if (issues.vulnerabilities) { + for (const vulnerability of issues.vulnerabilities) { + if (patchesOfInterest.includes(vulnerability.id)) { + snykPatches[vulnerability.package] = + snykPatches[vulnerability.package] || []; + const fetchedPatches = await Promise.all( + vulnerability.patches.map(async (patch) => { + return { + ...patch, + diffs: await Promise.all( + patch.urls.map(async (url) => httpsGet(url)), + ), + }; + }), + ); + snykPatches[vulnerability.package] = [...fetchedPatches]; + } + } + } + } + } + return snykPatches; +} + +// fetch patches => needle +export const httpsGet = async (url: string, options: any = {}): Promise => + new Promise((resolve, reject) => { + const parsedURL = new URL(url); + const requestOptions = { + ...options, + host: parsedURL.host, + path: parsedURL.pathname, + }; + const request = https.get(requestOptions, (response) => { + if ( + response.statusCode && + (response.statusCode < 200 || response.statusCode > 299) + ) { + reject( + new Error('Failed to load page, status code: ' + response.statusCode), + ); + } + const body: any[] = []; + response.on('data', (chunk: any) => body.push(chunk)); + response.on('end', () => + resolve(options.json ? JSON.parse(body.join('')) : body.join('')), + ); + }); + request.on('error', reject); + }); diff --git a/packages/snyk-protect/src/lib/index.ts b/packages/snyk-protect/src/lib/index.ts index 9cfaf72af3..1546634226 100644 --- a/packages/snyk-protect/src/lib/index.ts +++ b/packages/snyk-protect/src/lib/index.ts @@ -1,3 +1,53 @@ -export function doProtect() { - console.log("doing protect"); +import * as fs from 'fs'; +import * as path from 'path'; +import { extractPatchMetadata } from './snyk-file'; +import { applyPatchToFile } from './patch'; +import { getPatches } from './get-patches'; +import { checkProject } from './explore-node-modules'; +import { PhysicalModuleToPatch } from './types'; + +async function protect(projectFolderPath: string) { + const snykFilePath = path.resolve(projectFolderPath, '.snyk'); + + const snykFileContents = fs.readFileSync(snykFilePath, 'utf8'); + const snykFilePatchMetadata = extractPatchMetadata(snykFileContents); + + const patchesOfInterest: string[] = Object.keys(snykFilePatchMetadata); // a list of snyk vulnerability IDs + + // a list of package names (corresponding to the vulnerability IDs) + // can't use .flat() because it's not supported in Node 10 + const librariesOfInterest: string[] = []; + for (const nextArrayOfPackageNames of Object.values(snykFilePatchMetadata)) { + librariesOfInterest.push(...nextArrayOfPackageNames); + } + + const physicalModulesToPatch: PhysicalModuleToPatch[] = []; // this will be poplulated by checkProject and checkPhysicalModule + + // this fills in physicalModulesToPatch + checkProject(projectFolderPath, librariesOfInterest, physicalModulesToPatch); + + // TODO: type this + // it's a map of string -> something + const snykPatches = await getPatches( + physicalModulesToPatch, + patchesOfInterest, + ); + if (Object.keys(snykPatches).length === 0) { + console.log('Nothing to patch, done'); + return; + } + + for (const [libToPatch, patches] of Object.entries(snykPatches)) { + for (const place of physicalModulesToPatch.filter( + (l) => l.name === libToPatch, + )) { + for (const patch of patches as any) { + for (const patchDiff of (patch as any).diffs) { + applyPatchToFile(patchDiff, place.folderPath); + } + } + } + } } + +export default protect; diff --git a/packages/snyk-protect/src/lib/patch.ts b/packages/snyk-protect/src/lib/patch.ts new file mode 100644 index 0000000000..36435176e7 --- /dev/null +++ b/packages/snyk-protect/src/lib/patch.ts @@ -0,0 +1,73 @@ +import * as path from 'path'; +import * as fs from 'fs'; + +export function applyPatchToFile(patchContents: string, baseFolder: string) { + const targetFilePath = path.join( + baseFolder, + extractTargetFilePathFromPatch(patchContents), + ); + const contentsToPatch = fs.readFileSync(targetFilePath, 'utf-8'); + const patchedContents = patchString(patchContents, contentsToPatch); + fs.writeFileSync(targetFilePath, patchedContents); + console.log(`patched ${targetFilePath}`); +} + +export function extractTargetFilePathFromPatch(patchContents: string): string { + const patchContentLines = patchContents + .slice(patchContents.search(/^--- a\//m)) + .split('\n'); + const filename = patchContentLines[0].replace('--- a/', ''); + return filename; +} + +export function patchString( + patchContents: string, + contentsToPatch: string, +): string { + const patchContentLines = patchContents + .slice(patchContents.search(/^--- a\//m)) + .split('\n'); + + const contentsToPatchLines = contentsToPatch.split('\n'); + + if (!patchContentLines[2]) { + // return; + throw new Error('Invalid patch'); + } + const unparsedLineToPatch = /^@@ -(\d*),.*@@/.exec(patchContentLines[2]); + if (!unparsedLineToPatch || !unparsedLineToPatch[1]) { + // return; + throw new Error('Invalid patch'); + } + let lineToPatch = parseInt(unparsedLineToPatch[1], 10) - 2; + + const patchLines = patchContentLines.slice(3, patchContentLines.length - 2); + + for (const patchLine of patchLines) { + lineToPatch += 1; + switch (patchLine.charAt(0)) { + case '-': + contentsToPatchLines.splice(lineToPatch, 1); + break; + + case '+': + contentsToPatchLines.splice(lineToPatch, 0, patchLine.substring(1)); + break; + + case ' ': + if (contentsToPatchLines[lineToPatch] !== patchLine.slice(1)) { + console.log( + 'Expected\n line from local file\n', + contentsToPatchLines[lineToPatch], + ); + console.log('\n to match patch line\n', patchLine.slice(1), '\n'); + throw new Error( + // `File ${filename} to be patched does not match, not patching`, + `File to be patched does not match, not patching`, + ); + } + break; + } + } + return contentsToPatchLines.join('\n'); +} diff --git a/packages/snyk-protect/src/lib/snyk-file.ts b/packages/snyk-protect/src/lib/snyk-file.ts new file mode 100644 index 0000000000..dcd00c6bda --- /dev/null +++ b/packages/snyk-protect/src/lib/snyk-file.ts @@ -0,0 +1,41 @@ +const lineRegex = /^(\s*)(.*):(?:$| )+(.*)$/i; + +export function extractPatchMetadata( + dotSnykFileContent: string, +): { [vulnId: string]: string[] } { + let writingPatches = false; + let writingTo: string; + + // .snyk parsing => snyk-policy ( or js-yaml ) + const patches = dotSnykFileContent + .split('\n') + .filter((l) => l.length && !l.trimStart().startsWith('#')) + .map((line) => lineRegex.exec(line)) + .filter(Boolean) + .reduce((acc, thing) => { + const [, prefix, key, value] = thing as RegExpExecArray; + if (writingPatches && prefix === '') { + writingPatches = false; + } else if (prefix === '' && key === 'patch' && value === '') { + writingPatches = true; + } else if (writingPatches) { + if (prefix.length === 2) { + writingTo = key; + acc[key] = []; + } else { + if (key.startsWith('-')) { + const destination = key + .split('>') + .pop() + ?.trim(); + if (!acc[writingTo].includes(destination)) { + acc[writingTo].push(destination); + } + } + } + } + return acc; + }, {}); + + return patches; +} diff --git a/packages/snyk-protect/src/lib/types.ts b/packages/snyk-protect/src/lib/types.ts new file mode 100644 index 0000000000..bcde9455b7 --- /dev/null +++ b/packages/snyk-protect/src/lib/types.ts @@ -0,0 +1,19 @@ +export interface PhysicalModuleToPatch { + name: string; + version: string; + folderPath: string; +} + +export interface PackageAndVersion { + name: string; + version: string; +} + +export interface PatchDetails { + comments: string[]; + diffs: string[]; + id: string; + modifictionTime: string; + urls: string[]; + version: string; +} diff --git a/packages/snyk-protect/test/acceptance/protect-acceptance-tests.spec.ts b/packages/snyk-protect/test/acceptance/protect-acceptance-tests.spec.ts new file mode 100644 index 0000000000..a3a931b20d --- /dev/null +++ b/packages/snyk-protect/test/acceptance/protect-acceptance-tests.spec.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs'; +import protect from '../../src/lib'; +import * as path from 'path'; + +describe('new snyk protect', () => { + it('works', async () => { + const fixtureFolder = path.join( + __dirname, + '../fixtures/single-patchable-module', + ); + const origTargetFilePath = path.join( + fixtureFolder, + 'node_modules/nyc/node_modules/lodash/lodash.js.4.17.15.bak', + ); + const targeFilePath = path.join( + fixtureFolder, + 'node_modules/nyc/node_modules/lodash/lodash.js', + ); + const expectedPatchedFilePath = path.join( + fixtureFolder, + 'lodash-expected-patched.js', + ); + + try { + // make sure the target file is the orig one + const origTargetFileContents = fs.readFileSync(origTargetFilePath); + fs.writeFileSync(targeFilePath, origTargetFileContents); + await protect(fixtureFolder); + const actualPatchedFileContents = fs.readFileSync(targeFilePath); + const expectedPatchedFileContents = fs.readFileSync( + expectedPatchedFilePath, + ); + expect(actualPatchedFileContents).toEqual(expectedPatchedFileContents); + } finally { + // reset the target file + const origTargetFileContents = fs.readFileSync(origTargetFilePath); + fs.writeFileSync(targeFilePath, origTargetFileContents); + } + }); +}); diff --git a/packages/snyk-protect/test/fixtures/multiple-matching-paths/package-lock.json b/packages/snyk-protect/test/fixtures/multiple-matching-paths/package-lock.json index ae6ac5a65e..32f833643e 100644 --- a/packages/snyk-protect/test/fixtures/multiple-matching-paths/package-lock.json +++ b/packages/snyk-protect/test/fixtures/multiple-matching-paths/package-lock.json @@ -1,5 +1,5 @@ { - "name": "protect-fixture-1", + "name": "multiple-matching-paths", "version": "1.0.1", "lockfileVersion": 2, "requires": true, diff --git a/packages/snyk-protect/test/fixtures/multiple-matching-paths/package.json b/packages/snyk-protect/test/fixtures/multiple-matching-paths/package.json index 3ae64c45f8..c40f0b04f0 100644 --- a/packages/snyk-protect/test/fixtures/multiple-matching-paths/package.json +++ b/packages/snyk-protect/test/fixtures/multiple-matching-paths/package.json @@ -1,5 +1,5 @@ { - "name": "protect-fixture-1", + "name": "multiple-matching-paths", "version": "1.0.1", "description": "A test fixture", "repository": { diff --git a/packages/snyk-protect/test/fixtures/no-matching-paths/package-lock.json b/packages/snyk-protect/test/fixtures/no-matching-paths/package-lock.json index ae6ac5a65e..c7f75b0569 100644 --- a/packages/snyk-protect/test/fixtures/no-matching-paths/package-lock.json +++ b/packages/snyk-protect/test/fixtures/no-matching-paths/package-lock.json @@ -1,5 +1,5 @@ { - "name": "protect-fixture-1", + "name": "no-matching-paths", "version": "1.0.1", "lockfileVersion": 2, "requires": true, diff --git a/packages/snyk-protect/test/fixtures/no-matching-paths/package.json b/packages/snyk-protect/test/fixtures/no-matching-paths/package.json index 3ae64c45f8..27070f2433 100644 --- a/packages/snyk-protect/test/fixtures/no-matching-paths/package.json +++ b/packages/snyk-protect/test/fixtures/no-matching-paths/package.json @@ -1,5 +1,5 @@ { - "name": "protect-fixture-1", + "name": "no-matching-paths", "version": "1.0.1", "description": "A test fixture", "repository": { diff --git a/packages/snyk-protect/test/fixtures/single-patchable-module/package-lock.json b/packages/snyk-protect/test/fixtures/single-patchable-module/package-lock.json index ae6ac5a65e..a7ade57725 100644 --- a/packages/snyk-protect/test/fixtures/single-patchable-module/package-lock.json +++ b/packages/snyk-protect/test/fixtures/single-patchable-module/package-lock.json @@ -1,5 +1,5 @@ { - "name": "protect-fixture-1", + "name": "single-patchable-module", "version": "1.0.1", "lockfileVersion": 2, "requires": true, diff --git a/packages/snyk-protect/test/fixtures/single-patchable-module/package.json b/packages/snyk-protect/test/fixtures/single-patchable-module/package.json index 3ae64c45f8..712d6363dc 100644 --- a/packages/snyk-protect/test/fixtures/single-patchable-module/package.json +++ b/packages/snyk-protect/test/fixtures/single-patchable-module/package.json @@ -1,5 +1,5 @@ { - "name": "protect-fixture-1", + "name": "single-patchable-module", "version": "1.0.1", "description": "A test fixture", "repository": { diff --git a/packages/snyk-protect/test/unit/protect-unit-tests.spec.ts b/packages/snyk-protect/test/unit/protect-unit-tests.spec.ts new file mode 100644 index 0000000000..6902998c65 --- /dev/null +++ b/packages/snyk-protect/test/unit/protect-unit-tests.spec.ts @@ -0,0 +1,254 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { PhysicalModuleToPatch, PackageAndVersion } from '../../src/lib/types'; + +import { extractPatchMetadata } from '../../src/lib/snyk-file'; +import { checkProject } from '../../src/lib/explore-node-modules'; +import { getPatches } from '../../src/lib/get-patches'; +import { extractTargetFilePathFromPatch, patchString } from '../../src/lib/patch'; + +describe('parsing .snyk file content', () => { + it('works with a single patch', () => { + const dotSnykFileContents = ` +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.19.0 +ignore: {} +# patches apply the minimum changes required to fix a vulnerability +patch: + SNYK-JS-LODASH-567746: + - tap > nyc > istanbul-lib-instrument > babel-types > lodash: + patched: '2021-02-17T13:43:51.857Z' + `; + const snykFilePatchMetadata = extractPatchMetadata(dotSnykFileContents); + const vulnIds = Object.keys(snykFilePatchMetadata); + + // can't use .flat() because it's not supported in Node 10 + const packageNames: string[] = []; + for (const nextArrayOfPackageNames of Object.values( + snykFilePatchMetadata, + )) { + packageNames.push(...nextArrayOfPackageNames); + } + + expect(vulnIds).toEqual(['SNYK-JS-LODASH-567746']); + expect(packageNames).toEqual(['lodash']); + }); + + it('works with multiple patches', async () => { + const dotSnykFileContents = ` +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.19.0 +ignore: {} +# patches apply the minimum changes required to fix a vulnerability +patch: + SNYK-JS-LODASH-567746: + - tap > nyc > istanbul-lib-instrument > babel-types > lodash: + patched: '2021-02-17T13:43:51.857Z' + + SNYK-FAKE-THEMODULE-000000: + - top-level > some-other > the-module: + patched: '2021-02-17T13:43:51.857Z' + `; + const snykFilePatchMetadata = extractPatchMetadata(dotSnykFileContents); + const vulnIds = Object.keys(snykFilePatchMetadata); + + // can't use .flat() because it's not supported in Node 10 + const packageNames: string[] = []; + for (const nextArrayOfPackageNames of Object.values( + snykFilePatchMetadata, + )) { + packageNames.push(...nextArrayOfPackageNames); + } + + expect(vulnIds).toEqual([ + 'SNYK-JS-LODASH-567746', + 'SNYK-FAKE-THEMODULE-000000', + ]); + expect(packageNames).toEqual(['lodash', 'the-module']); + }); + + it('works with zero patches defined in patch section', async () => { + const dotSnykFileContents = ` +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.19.0 +ignore: {} +# patches apply the minimum changes required to fix a vulnerability +patch: +`; + const snykFilePatchMetadata = extractPatchMetadata(dotSnykFileContents); + const vulnIds = Object.keys(snykFilePatchMetadata); + + // can't use .flat() because it's not supported in Node 10 + const packageNames: string[] = []; + for (const nextArrayOfPackageNames of Object.values( + snykFilePatchMetadata, + )) { + packageNames.push(...nextArrayOfPackageNames); + } + + expect(vulnIds).toHaveLength(0); + expect(packageNames).toHaveLength(0); + }); + + it('works with no patch section', async () => { + const dotSnykFileContents = ` +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.19.0 +ignore: {} +`; + const snykFilePatchMetadata = extractPatchMetadata(dotSnykFileContents); + const vulnIds = Object.keys(snykFilePatchMetadata); + + // can't use .flat() because it's not supported in Node 10 + const packageNames: string[] = []; + for (const nextArrayOfPackageNames of Object.values( + snykFilePatchMetadata, + )) { + packageNames.push(...nextArrayOfPackageNames); + } + + expect(vulnIds).toHaveLength(0); + expect(packageNames).toHaveLength(0); + }); +}); + +describe('checkProject', () => { + it('works with no matching physical modules', () => { + const fixtureFolderRelativePath = '../fixtures/no-matching-paths'; + const fixtureFolder = path.join(__dirname, fixtureFolderRelativePath); + + const physicalModulesToPatch: PhysicalModuleToPatch[] = []; // this will get populated by checkProject + checkProject(fixtureFolder, ['lodash'], physicalModulesToPatch); + + expect(physicalModulesToPatch).toHaveLength(0); + }); + + it('works with single matching physical module', () => { + const fixtureFolderRelativePath = '../fixtures/single-patchable-module'; + const fixtureFolder = path.join(__dirname, fixtureFolderRelativePath); + + const physicalModulesToPatch: PhysicalModuleToPatch[] = []; // this will get populated by checkProject + checkProject(fixtureFolder, ['lodash'], physicalModulesToPatch); + + expect(physicalModulesToPatch).toHaveLength(1); + const m = physicalModulesToPatch[0]; + expect(m.name).toBe('lodash'); + expect(m.version).toBe('4.17.15'); + expect(m.folderPath).toEqual( + path.join( + __dirname, + fixtureFolderRelativePath, + '/node_modules/nyc/node_modules/lodash', + ), + ); + }); + + it('works with multiple matching physical modules', () => { + const fixtureFolderRelativePath = '../fixtures/multiple-matching-paths'; + const fixtureFolder = path.join(__dirname, fixtureFolderRelativePath); + + const physicalModulesToPatch: PhysicalModuleToPatch[] = []; // this will get populated by checkProject + checkProject(fixtureFolder, ['lodash'], physicalModulesToPatch); + + expect(physicalModulesToPatch).toHaveLength(2); + const m0 = physicalModulesToPatch[0]; + expect(m0.name).toBe('lodash'); + expect(m0.version).toBe('4.17.15'); + expect(m0.folderPath).toEqual( + path.join(__dirname, fixtureFolderRelativePath, '/node_modules/lodash'), + ); + const m1 = physicalModulesToPatch[1]; + expect(m1.name).toBe('lodash'); + expect(m1.version).toBe('4.17.15'); + expect(m1.folderPath).toEqual( + path.join( + __dirname, + fixtureFolderRelativePath, + '/node_modules/nyc/node_modules/lodash', + ), + ); + }); +}); + +// These tests makes a real API calls to Snyk +// TODO: would be better to mock the response +describe('getPatches', () => { + it('seems to work', async () => { + const packageAndVersions: PackageAndVersion[] = [ + { + name: 'lodash', + version: '4.17.15', + } as PackageAndVersion, + ]; + const vulnIds = ['SNYK-JS-LODASH-567746']; + const patches = await getPatches(packageAndVersions, vulnIds); + expect(Object.keys(patches)).toEqual(['lodash']); + const lodashPatches = patches['lodash']; + expect(lodashPatches).toHaveLength(1); + const theOnePatch = lodashPatches[0]; + expect(theOnePatch.id).toBe('patch:SNYK-JS-LODASH-567746:0'); + expect(theOnePatch.diffs).toHaveLength(1); + expect(theOnePatch.diffs[0]).toContain('index 9b95dfef..43e71ffb 100644'); // something from the actual patch + }); + + it('does not download patch for non-applicable version', async () => { + const packageAndVersions: PackageAndVersion[] = [ + { + name: 'lodash', + version: '4.17.20', // this version is not applicable to the patch + } as PackageAndVersion, + ]; + const vulnIds = ['SNYK-JS-LODASH-567746']; + const patches = await getPatches(packageAndVersions, vulnIds); + expect(patches).toEqual({}); // expect nothing to be returned because SNYK-JS-LODASH-567746 does not apply to 4.17.20 of lodash + }); +}); + +describe('applying patches', () => { + it('can apply a patch using string', () => { + const fixtureFolder = path.join( + __dirname, + '../fixtures/patchable-file-lodash', + ); + const patchFilePath = path.join(fixtureFolder, 'lodash.patch'); + + const patchContents = fs.readFileSync(patchFilePath, 'utf-8'); + + const targetFilePath = path.join( + fixtureFolder, + extractTargetFilePathFromPatch(patchContents), + ); + const contentsToPatch = fs.readFileSync(targetFilePath, 'utf-8'); + + const patchedContents = patchString(patchContents, contentsToPatch); + + const expectedPatchedContentsFilePath = path.join( + fixtureFolder, + 'lodash-expected-patched.js', + ); + const expectedPatchedContents = fs.readFileSync( + expectedPatchedContentsFilePath, + 'utf-8', + ); + expect(patchedContents).toBe(expectedPatchedContents); + expect(0).toBe(0); + }); + + // if the patch is not compatible with the target, make sure we throw an Error and do patch + it('will throw if patch does not match target', () => { + const fixtureFolder = path.join( + __dirname, + '../fixtures/non-patchable-file-because-non-matching', + ); + const patchFilePath = path.join(fixtureFolder, 'lodash.patch'); + const patchContents = fs.readFileSync(patchFilePath, 'utf-8'); + const targetFilePath = path.join( + fixtureFolder, + extractTargetFilePathFromPatch(patchContents), + ); + const contentsToPatch = fs.readFileSync(targetFilePath, 'utf-8'); + expect(() => { + patchString(patchContents, contentsToPatch); + }).toThrow(Error); + }); +});