From 564add77748eb3721d34f64cb8c68041f899383a Mon Sep 17 00:00:00 2001 From: dgpatelgit Date: Tue, 27 Oct 2020 10:57:26 +0530 Subject: [PATCH] feat: [APPAI-1524] Golang CA support from LSP (#148) * [APPAI-1484] Support for single line require and version string parsing using regex * [APPAI-1524] Golang CA for modules and packages * Removed API server directing to local server * Added unit test case for multiple package use-case * No more saving data of command to a file, review comments implemented * Used join for imports to simplify looping * Optimized import reading and made it async * Review comments, added test case for module + import testing * Created package aggregator to merge module and package data * Created noop aggregator, mock exec for test cases, added semicolon * Moved package and aggregator into seperate file, added unit test to cover 100% code * Some more semi-colons missing * Some more semicolons, removed unwanted changes, foreach for loop * Removed ecosystem from base class * Renamed package to vulnerability as per review comment * Removed unsed class, moved aggregator to pipline from producer --- package-lock.json | 27 +++ package.json | 6 +- src/aggregators.ts | 99 ++++++++++ src/collector.ts | 44 ++++- src/consumers.ts | 143 +++++++-------- src/server.ts | 38 ++-- src/vulnerability.ts | 70 +++++++ test/aggregators.test.ts | 193 ++++++++++++++++++++ test/consumer.test.ts | 88 ++++++++- test/gomod.collector.test.ts | 341 ++++++++++++++++++----------------- test/npm.collector.test.ts | 1 - test/vulnerability.test.ts | 103 +++++++++++ 12 files changed, 879 insertions(+), 274 deletions(-) create mode 100644 src/aggregators.ts create mode 100644 src/vulnerability.ts create mode 100644 test/aggregators.test.ts create mode 100644 test/vulnerability.test.ts diff --git a/package-lock.json b/package-lock.json index ddf41e97..e7ed5454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -664,6 +664,12 @@ "integrity": "sha1-oMoMvCmltz6Dbuvhy/bF4OTrgvk=", "dev": true }, + "array-find": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-find/-/array-find-1.0.0.tgz", + "integrity": "sha1-bI4obRHtdoMn+OYuzuhzU8o+eLg=", + "dev": true + }, "array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", @@ -975,6 +981,11 @@ "dot-prop": "^5.1.0" } }, + "compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1410,6 +1421,16 @@ } } }, + "fake-exec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fake-exec/-/fake-exec-1.1.0.tgz", + "integrity": "sha1-t4k+BNnr6mnuMJzT2rSDDjTyuik=", + "dev": true, + "requires": { + "array-find": "^1.0.0", + "remove-value": "^1.0.0" + } + }, "fast-glob": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz", @@ -7235,6 +7256,12 @@ "es6-error": "^4.0.1" } }, + "remove-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/remove-value/-/remove-value-1.0.0.tgz", + "integrity": "sha1-uKmd0TbRbt5YsZvKjnkjVbqt0SM=", + "dev": true + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 5c86f5a3..d35a885f 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "node-fetch": "^2.6.0", "vscode-languageserver": "^5.3.0-next.9", "winston": "3.2.1", - "xml2object": "0.1.2" + "xml2object": "0.1.2", + "compare-versions": "3.6.0" }, "devDependencies": { "@semantic-release/exec": "^5.0.0", @@ -47,7 +48,8 @@ "nyc": "^14.1.1", "semantic-release": "^17.1.0", "ts-node": "^8.3.0", - "typescript": "^3.6.3" + "typescript": "^3.6.3", + "fake-exec": "^1.1.0" }, "scripts": { "build": "tsc -p . && cp package.json LICENSE README.md output", diff --git a/src/aggregators.ts b/src/aggregators.ts new file mode 100644 index 00000000..9ab4dc0a --- /dev/null +++ b/src/aggregators.ts @@ -0,0 +1,99 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Dharmendra Patel 2020 + * Licensed under the Apache-2.0 License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ +'use strict'; +import { Vulnerability } from './vulnerability'; +import compareVersions = require('compare-versions'); + +const severity = ["low", "medium", "high", "critical"]; + +/* VulnerabilityAggregator */ +interface VulnerabilityAggregator { + isNewVulnerability: boolean; + aggregate(newVulnerability: Vulnerability): Vulnerability; +} + +/* Noop Vulnerability aggregator class */ +class NoopVulnerabilityAggregator implements VulnerabilityAggregator { + isNewVulnerability: boolean; + + aggregate(newVulnerability: Vulnerability): Vulnerability { + // Make it a new vulnerability always and set ecosystem for vulnerability. + this.isNewVulnerability = true; + newVulnerability.ecosystem = ""; + + return newVulnerability; + } +} + +/* Golang Vulnerability aggregator class */ +class GolangVulnerabilityAggregator implements VulnerabilityAggregator { + vulnerabilities: Array = Array(); + isNewVulnerability: boolean; + + aggregate(newVulnerability: Vulnerability): Vulnerability { + // Set ecosystem for new vulnerability from aggregator + newVulnerability.ecosystem = "golang"; + + // Check if module / package exists in the list. + this.isNewVulnerability = true; + + var existingVulnerabilityIndex = 0 + this.vulnerabilities.forEach((pckg, index) => { + // Module and package can come in any order due to parallel batch requests. + // Need handle use case (1) Module first followed by package and (2) Vulnerability first followed by module. + if (newVulnerability.name.startsWith(pckg.name + "/") || pckg.name.startsWith(newVulnerability.name + "/")) { + // Module / package exists, so aggregate the data and update Diagnostic message and code action. + this.mergeVulnerability(index, newVulnerability); + this.isNewVulnerability = false; + existingVulnerabilityIndex = index; + } + }); + + if (this.isNewVulnerability) { + this.vulnerabilities.push(newVulnerability); + return newVulnerability; + } + + return this.vulnerabilities[existingVulnerabilityIndex]; + } + + private mergeVulnerability(existingIndex: number, newVulnerability: Vulnerability) { + // Between current name and new name, smallest will be the module name. + // So, assign the smallest as package name. + if (newVulnerability.name.length < this.vulnerabilities[existingIndex].name.length) + this.vulnerabilities[existingIndex].name = newVulnerability.name; + + // Merge other informations + this.vulnerabilities[existingIndex].packageCount += newVulnerability.packageCount; + this.vulnerabilities[existingIndex].vulnerabilityCount += newVulnerability.vulnerabilityCount; + this.vulnerabilities[existingIndex].advisoryCount += newVulnerability.advisoryCount; + this.vulnerabilities[existingIndex].exploitCount += newVulnerability.exploitCount; + this.vulnerabilities[existingIndex].highestSeverity = this.getMaxSeverity( + this.vulnerabilities[existingIndex].highestSeverity, newVulnerability.highestSeverity); + this.vulnerabilities[existingIndex].recommendedVersion = this.getMaxRecVersion( + this.vulnerabilities[existingIndex].recommendedVersion, newVulnerability.recommendedVersion); + } + + private getMaxSeverity(oldSeverity: string, newSeverity: string): string { + const newSeverityIndex = Math.max(severity.indexOf(oldSeverity), severity.indexOf(newSeverity)); + return severity[newSeverityIndex]; + } + + private getMaxRecVersion(oldRecVersion: string, newRecVersion: string): string { + // Compute maximium recommended version. + var maxRecVersion = oldRecVersion; + if (oldRecVersion == "" || oldRecVersion == null) { + maxRecVersion = newRecVersion; + } else if (newRecVersion != "" && newRecVersion != null) { + if (compareVersions(oldRecVersion, newRecVersion) == -1) { + maxRecVersion = newRecVersion; + } + } + + return maxRecVersion; + } +} + +export { VulnerabilityAggregator, NoopVulnerabilityAggregator, GolangVulnerabilityAggregator }; diff --git a/src/collector.ts b/src/collector.ts index 8e2c315e..1c1cb830 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -8,6 +8,7 @@ import * as Xml2Object from 'xml2object'; import * as jsonAst from 'json-to-ast'; import { IPosition, IKeyValueEntry, KeyValueEntry, Variant, ValueType } from './types'; import { stream_from_string } from './utils'; +import { exec } from 'child_process'; /* Please note :: There was issue with semverRegex usage in the code. During run time, it extracts * version with 'v' prefix, but this is not be behavior of semver in CLI and test environment. @@ -96,15 +97,14 @@ class ReqDependencyCollector implements IDependencyCollector { } class NaiveGomodParser { - constructor(contents: string) { - this.dependencies = NaiveGomodParser.parseDependencies(contents); + constructor(contents: string, goImports: Set) { + this.dependencies = NaiveGomodParser.parseDependencies(contents, goImports); } dependencies: Array; - static parseDependencies(contents:string): Array { - const gomod = contents.split("\n"); - return gomod.reduce((dependencies, line, index) => { + static parseDependencies(contents:string, goImports: Set): Array { + return contents.split("\n").reduce((dependencies, line, index) => { // Ignore "replace" lines if (!line.includes("=>")) { // skip any text after '//' @@ -115,16 +115,26 @@ class NaiveGomodParser { //const version = semverRegex().exec(line) regExp.lastIndex = 0; const version = regExp.exec(line); - // Skip lines without version string + // Skip lines without version string if (version && version.length > 0) { - const parts: Array = line.replace('require', '').replace('(', '').replace(')', '').trim().split(' '); - const pkgName:string = (parts[0] || '').trim(); + const parts: Array = line.replace('require', '').replace('(', '').replace(')', '').trim().split(' '); + const pkgName: string = (parts[0] || '').trim(); // Ignore line starting with replace clause and empty package if (pkgName.length > 0) { const entry: IKeyValueEntry = new KeyValueEntry(pkgName, { line: 0, column: 0 }); entry.value = new Variant(ValueType.String, 'v' + version[0]); entry.value_position = { line: index + 1, column: version.index }; dependencies.push(new Dependency(entry)); + + // Find packages of this module. + goImports.forEach(pckg => { + if (pckg != pkgName && pckg.startsWith(pkgName + "/")) { + const entry: IKeyValueEntry = new KeyValueEntry(pckg, { line: 0, column: 0 }); + entry.value = new Variant(ValueType.String, 'v' + version[0]); + entry.value_position = { line: index + 1, column: version.index }; + dependencies.push(new Dependency(entry)); + } + }) } } } @@ -140,10 +150,24 @@ class NaiveGomodParser { /* Process entries found in the go.mod file and collect all dependency * related information */ class GomodDependencyCollector implements IDependencyCollector { - constructor(public classes: Array = ["dependencies"]) {} + constructor(private manifestFile: string, public classes: Array = ["dependencies"]) { + this.manifestFile = manifestFile; + } async collect(contents: string): Promise> { - let parser = new NaiveGomodParser(contents); + let promiseExec = new Promise>((resolve, reject) => { + const vscodeRootpath = this.manifestFile.replace("file://", "").replace("/go.mod", "") + exec(`go list -f '{{ join .Imports "\\n" }}' ./...`, + { cwd: vscodeRootpath, maxBuffer: 1024 * 1200 }, (error, stdout, stderr) => { + if (error) { + reject(`'go list' command failed with error :: ${stderr}`); + } else { + resolve(new Set(stdout.toString().split("\n"))); + } + }); + }); + const goImports: Set = await promiseExec; + let parser = new NaiveGomodParser(contents, goImports); return parser.parse(); } diff --git a/src/consumers.ts b/src/consumers.ts index f7188acc..3ff2a55a 100644 --- a/src/consumers.ts +++ b/src/consumers.ts @@ -5,7 +5,9 @@ 'use strict'; import { IDependency } from './collector'; import { get_range } from './utils'; -import { Diagnostic, DiagnosticSeverity, CodeAction, CodeActionKind } from 'vscode-languageserver' +import { Vulnerability } from './vulnerability'; +import { VulnerabilityAggregator } from './aggregators'; +import { Diagnostic, CodeAction, CodeActionKind } from 'vscode-languageserver' /* Descriptor describing what key-path to extract from the document */ interface IBindingDescriptor { @@ -34,7 +36,7 @@ interface IConsumer { /* Generic `T` producer */ interface IProducer { - produce(ctx: any): T; + produce(): T; }; /* Each pipeline item is defined as a single consumer and producer pair */ @@ -47,38 +49,79 @@ interface IPipeline { }; /* Diagnostics producer type */ -type DiagnosticProducer = IProducer; +type DiagnosticProducer = IProducer; /* Diagnostics pipeline implementation */ -class DiagnosticsPipeline implements IPipeline +class DiagnosticsPipeline implements IPipeline { - items: Array>; + items: Array>; dependency: IDependency; config: any; diagnostics: Array; uri: string; - constructor(classes: Array, dependency: IDependency, config: any, diags: Array, uri: string) { + vulnerabilityAggregator: VulnerabilityAggregator; + constructor(classes: Array, dependency: IDependency, config: any, diags: Array, + vulnerabilityAggregator: VulnerabilityAggregator, uri: string) { this.items = classes.map((i) => { return new i(dependency, config); }); this.dependency = dependency; this.config = config; this.diagnostics = diags; this.uri = uri; + this.vulnerabilityAggregator = vulnerabilityAggregator; } - run(data: any): Diagnostic[] { + run(data: any): Vulnerability[] { for (let item of this.items) { if (item.consume(data)) { - for (let d of item.produce(this.uri)) - this.diagnostics.push(d); + for (let d of item.produce()) { + const aggVulnerability = this.vulnerabilityAggregator.aggregate(d); + const aggDiagnostic = aggVulnerability.getDiagnostic(); + + // Add/Update quick action for given aggregated diangnostic + // TODO: this can be done lazily + if (aggVulnerability.recommendedVersion && (aggVulnerability.vulnerabilityCount > 0 || aggVulnerability.exploitCount != null)) { + let codeAction: CodeAction = { + title: `Switch to recommended version ${aggVulnerability.recommendedVersion}`, + diagnostics: [aggDiagnostic], + kind: CodeActionKind.QuickFix, // Provide a QuickFix option if recommended version is available + edit: { + changes: { + } + } + }; + codeAction.edit.changes[this.uri] = [{ + range: aggDiagnostic.range, + newText: aggVulnerability.recommendedVersion + }]; + // We will have line|start as key instead of message + codeActionsMap[aggDiagnostic.range.start.line + "|" + aggDiagnostic.range.start.character] = codeAction; + } + + if (this.vulnerabilityAggregator.isNewVulnerability) { + this.diagnostics.push(aggDiagnostic); + } else { + // Update the existing diagnostic object based on range values + this.diagnostics.forEach((diag, index) => { + if (diag.range.start.line == aggVulnerability.range.start.line && + diag.range.start.character == aggVulnerability.range.start.character) { + this.diagnostics[index] = aggDiagnostic; + return; + } + }); + } + } } } - return this.diagnostics; + // This is not used by any one. + return []; } }; /* A consumer that uses the binding interface to consume a metadata object */ class AnalysisConsumer implements IConsumer { binding: IBindingDescriptor; + packageBinding: IBindingDescriptor; + versionBinding: IBindingDescriptor; changeToBinding: IBindingDescriptor; messageBinding: IBindingDescriptor; vulnerabilityCountBinding: IBindingDescriptor; @@ -86,6 +129,8 @@ class AnalysisConsumer implements IConsumer { exploitCountBinding: IBindingDescriptor; highestSeverityBinding: IBindingDescriptor; item: any; + package: string = null; + version: string = null; changeTo: string = null; message: string = null; vulnerabilityCount: number = 0; @@ -99,6 +144,12 @@ class AnalysisConsumer implements IConsumer { } else { this.item = data; } + if (this.packageBinding != null) { + this.package = bind_object(data, this.packageBinding); + } + if (this.versionBinding != null) { + this.version = bind_object(data, this.versionBinding); + } if (this.changeToBinding != null) { this.changeTo = bind_object(data, this.changeToBinding); } @@ -121,32 +172,13 @@ class AnalysisConsumer implements IConsumer { } }; -/* We've received an empty/unfinished result, display that analysis is pending */ -class EmptyResultEngine extends AnalysisConsumer implements DiagnosticProducer { - constructor(public context: IDependency, config: any) { - super(config); - } - - produce(): Diagnostic[] { - if (this.item == {} && (this.item.finished_at === undefined || - this.item.finished_at == null)) { - return [{ - severity: DiagnosticSeverity.Information, - range: get_range(this.context.version), - message: `Application dependency ${this.context.name.value}-${this.context.version.value} - analysis is pending`, - source: 'Dependency Analytics Plugin [Powered by Snyk]' - }] - } else { - return []; - } - } -} - /* Report CVEs in found dependencies */ class SecurityEngine extends AnalysisConsumer implements DiagnosticProducer { constructor(public context: IDependency, config: any) { super(config); this.binding = { path: ['vulnerability'] }; + this.packageBinding = { path: ['package'] }; + this.versionBinding = { path: ['version'] }; /* recommendation to use a different version */ this.changeToBinding = { path: ['recommended_versions'] }; /* Diagnostic message */ @@ -161,50 +193,11 @@ class SecurityEngine extends AnalysisConsumer implements DiagnosticProducer { this.highestSeverityBinding = { path: ['highest_severity'] }; } - produce(ctx: any): Diagnostic[] { + produce(): Vulnerability[] { if (this.item.length > 0) { - /* The diagnostic's severity. */ - let diagSeverity; - - if (this.vulnerabilityCount == 0 && this.advisoryCount > 0) { - diagSeverity = DiagnosticSeverity.Information; - } else { - diagSeverity = DiagnosticSeverity.Error; - } - - const recommendedVersion = this.changeTo || "N/A"; - const exploitCount = this.exploitCount || "unavailable"; - const msg = `${this.context.name.value}: ${this.context.version.value} -Known security vulnerability: ${this.vulnerabilityCount} -Security advisory: ${this.advisoryCount} -Exploits: ${exploitCount} -Highest severity: ${this.highestSeverity} -Recommendation: ${recommendedVersion}`; - let diagnostic = { - severity: diagSeverity, - range: get_range(this.context.version), - message: msg, - source: '\nDependency Analytics Plugin [Powered by Snyk]', - }; - - // TODO: this can be done lazily - if (this.changeTo && (this.vulnerabilityCount > 0 || this.exploitCount != null)) { - let codeAction: CodeAction = { - title: "Switch to recommended version " + this.changeTo, - diagnostics: [diagnostic], - kind: CodeActionKind.QuickFix, // Provide a QuickFix option if recommended version is available - edit: { - changes: { - } - } - }; - codeAction.edit.changes[ctx] = [{ - range: diagnostic.range, - newText: this.changeTo - }]; - codeActionsMap[diagnostic.message] = codeAction - } - return [diagnostic] + return [new Vulnerability(this.package, this.version, 1, + this.vulnerabilityCount, this.advisoryCount, this.exploitCount, this.highestSeverity, + this.changeTo, get_range(this.context.version))]; } else { return []; } @@ -213,4 +206,4 @@ Recommendation: ${recommendedVersion}`; let codeActionsMap = new Map(); -export { DiagnosticsPipeline, SecurityEngine, EmptyResultEngine, codeActionsMap }; +export { DiagnosticsPipeline, SecurityEngine, codeActionsMap }; diff --git a/src/server.ts b/src/server.ts index d150ade5..9ec3f308 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,14 +7,13 @@ import * as path from 'path'; import * as fs from 'fs'; import { IPCMessageReader, IPCMessageWriter, createConnection, IConnection, - TextDocuments, Diagnostic, InitializeResult, CodeLens, CodeAction, RequestHandler, CodeActionParams -} from 'vscode-languageserver'; -import { IDependency, IDependencyCollector, PackageJsonCollector, PomXmlDependencyCollector, ReqDependencyCollector, GomodDependencyCollector } from './collector'; -import { EmptyResultEngine, SecurityEngine, DiagnosticsPipeline, codeActionsMap } from './consumers'; + TextDocuments, InitializeResult, CodeLens, CodeAction} from 'vscode-languageserver'; +import { IDependencyCollector, PackageJsonCollector, PomXmlDependencyCollector, ReqDependencyCollector, GomodDependencyCollector } from './collector'; +import { SecurityEngine, DiagnosticsPipeline, codeActionsMap } from './consumers'; +import { NoopVulnerabilityAggregator, GolangVulnerabilityAggregator } from './aggregators'; import fetch from 'node-fetch'; const url = require('url'); -const https = require('https'); const winston = require('winston'); let transport; @@ -230,10 +229,10 @@ class TotalCount { }; /* Runs DiagnosticPileline to consume response and generate Diagnostic[] */ -function runPipeline(response, diagnostics, diagnosticFilePath, dependencyMap, totalCount) { +function runPipeline(response, diagnostics, packageAggregator, diagnosticFilePath, dependencyMap, totalCount) { response.forEach(r => { const dependency = dependencyMap.get(r.package + r.version); - let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, diagnosticFilePath); + let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, packageAggregator, diagnosticFilePath); pipeline.run(r); for (const item of pipeline.items) { const secEng = item as SecurityEngine; @@ -260,10 +259,25 @@ function slicePayload(payload, batchSize, ecosystem): any { const regexVersion = new RegExp(/^([a-zA-Z0-9]+\.)?([a-zA-Z0-9]+\.)?([a-zA-Z0-9]+\.)?([a-zA-Z0-9]+)$/); const sendDiagnostics = async (ecosystem: string, diagnosticFilePath: string, contents: string, collector: IDependencyCollector) => { connection.sendNotification('caNotification', {'data': caDefaultMsg}); - const deps = await collector.collect(contents); - let validPackages = deps + let deps = null; + try { + deps = await collector.collect(contents); + } catch (error) { + // Error can be raised during golang `go list ` command only. + if (ecosystem == "golang") { + console.error("Command execution failed, something wrong with manifest file go.mod\n%s", error); + connection.sendNotification('caError', {'data': 'Unable to execute `go list` command, run `go mod tidy` to resolve dependencies issues'}); + return; + } + } + + let validPackages = deps; + let packageAggregator = null; if (ecosystem != "golang") { validPackages = deps.filter(d => regexVersion.test(d.version.value.trim())); + packageAggregator = new NoopVulnerabilityAggregator(); + } else { + packageAggregator = new GolangVulnerabilityAggregator(); } const requestPayload = validPackages.map(d => ({"package": d.name.value, "version": d.version.value})); const requestMapper = new Map(validPackages.map(d => [d.name.value + d.version.value, d])); @@ -273,7 +287,7 @@ const sendDiagnostics = async (ecosystem: string, diagnosticFilePath: string, co const start = new Date().getTime(); const allRequests = slicePayload(requestPayload, batchSize, ecosystem).map(request => fetchVulnerabilities(request).then(response => - runPipeline(response, diagnostics, diagnosticFilePath, requestMapper, totalCount))); + runPipeline(response, diagnostics, packageAggregator, diagnosticFilePath, requestMapper, totalCount))); await Promise.allSettled(allRequests); const end = new Date().getTime(); @@ -294,7 +308,7 @@ files.on(EventStream.Diagnostics, "^requirements\\.txt$", (uri, name, contents) }); files.on(EventStream.Diagnostics, "^go\\.mod$", (uri, name, contents) => { - sendDiagnostics('golang', uri, contents, new GomodDependencyCollector()); + sendDiagnostics('golang', uri, contents, new GomodDependencyCollector(uri)); }); let checkDelay; @@ -320,7 +334,7 @@ connection.onCodeAction((params, token): CodeAction[] => { clearTimeout(checkDelay); let codeActions: CodeAction[] = []; for (let diagnostic of params.context.diagnostics) { - let codeAction = codeActionsMap[diagnostic.message]; + let codeAction = codeActionsMap[diagnostic.range.start.line + "|" + diagnostic.range.start.character]; if (codeAction != null) { codeActions.push(codeAction) } diff --git a/src/vulnerability.ts b/src/vulnerability.ts new file mode 100644 index 00000000..9704026e --- /dev/null +++ b/src/vulnerability.ts @@ -0,0 +1,70 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Dharmendra Patel 2020 + * Licensed under the Apache-2.0 License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ +'use strict'; +import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver' +import { Range } from 'vscode-languageserver'; + +/* Vulnerability data along with package name and version */ +class Vulnerability { + ecosystem: string; + name: string; + version: string; + packageCount: number; + vulnerabilityCount: number; + advisoryCount: number; + exploitCount: number; + highestSeverity: string; + recommendedVersion: string; + range: Range; + + constructor( + name: string, version: string, packageCount: number, + vulnerabilityCount: number, advisoryCount: number, + exploitCount: number, highestSeverity: string, + recommendedVersion: string, range: Range) { + this.name = name; + this.version = version; + this.packageCount = packageCount || 0; + this.vulnerabilityCount = vulnerabilityCount || 0; + this.advisoryCount = advisoryCount || 0; + this.exploitCount = exploitCount || 0; + this.highestSeverity = highestSeverity; + this.recommendedVersion = recommendedVersion; + this.range = range; + } + + getDiagnostic(): Diagnostic { + /* The diagnostic's severity. */ + let diagSeverity: any; + + if (this.vulnerabilityCount == 0 && this.advisoryCount > 0) { + diagSeverity = DiagnosticSeverity.Information; + } else { + diagSeverity = DiagnosticSeverity.Error; + } + + const recommendedVersion = this.recommendedVersion || "N/A"; + const exploitCount = this.exploitCount || "unavailable"; + var numberOfPackagesMsg = ""; + if (this.ecosystem == "golang") { + numberOfPackagesMsg = `\nNumber of packages: ${this.packageCount}`; + } + const msg = `${this.name}: ${this.version}${numberOfPackagesMsg} +Known security vulnerability: ${this.vulnerabilityCount} +Security advisory: ${this.advisoryCount} +Exploits: ${exploitCount} +Highest severity: ${this.highestSeverity} +Recommendation: ${recommendedVersion}`; + + return { + severity: diagSeverity, + range: this.range, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + } +} + +export { Vulnerability }; diff --git a/test/aggregators.test.ts b/test/aggregators.test.ts new file mode 100644 index 00000000..9f89f752 --- /dev/null +++ b/test/aggregators.test.ts @@ -0,0 +1,193 @@ +import { expect } from 'chai'; +import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver'; +import { GolangVulnerabilityAggregator, NoopVulnerabilityAggregator } from '../src/aggregators'; +import { Vulnerability } from '../src/vulnerability'; + +const dummyRange: Range = { + start: { + line: 3, + character: 4 + }, + end: { + line: 3, + character: 10 + } +}; + +describe('Noop vulnerability aggregator tests', () => { + + it('Test noop aggregator with one vulnerability', async () => { + let pckg = new Vulnerability("abc", "1.4.3", 1, 2, 1, 1, "high", "2.3.1", dummyRange); + let noopVulnerabilityAggregator = new NoopVulnerabilityAggregator(); + pckg = noopVulnerabilityAggregator.aggregate(pckg); + + const msg = "abc: 1.4.3\nKnown security vulnerability: 2\nSecurity advisory: 1\nExploits: 1\nHighest severity: high\nRecommendation: 2.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test noop aggregator with two vulnerability', async () => { + let pckg1 = new Vulnerability("abc", "1.4.3", 1, 2, 1, 1, "high", "2.3.1", dummyRange); + let noopVulnerabilityAggregator = new NoopVulnerabilityAggregator(); + var pckg = noopVulnerabilityAggregator.aggregate(pckg1); + + let pckg2 = new Vulnerability("abc/pck", "2.4.3", 1, 3, 2, 2, "low", "3.3.1", dummyRange); + pckg = noopVulnerabilityAggregator.aggregate(pckg2); + + const msg = "abc/pck: 2.4.3\nKnown security vulnerability: 3\nSecurity advisory: 2\nExploits: 2\nHighest severity: low\nRecommendation: 3.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + // Noop should not aggregate any data, it should be same as PCKG2 + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); +}); + +describe('Golang vulnerability aggregator tests', () => { + it('Test golang aggregator with one vulnerability', async () => { + let pckg = new Vulnerability("abc", "1.4.3", 1, 2, 1, 1, "high", "2.3.1", dummyRange); + let golangVulnerabilityAggregator = new GolangVulnerabilityAggregator(); + pckg = golangVulnerabilityAggregator.aggregate(pckg); + + const msg = "abc: 1.4.3\nNumber of packages: 1\nKnown security vulnerability: 2\nSecurity advisory: 1\nExploits: 1\nHighest severity: high\nRecommendation: 2.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test golang aggregator with two vulnerability', async () => { + let pckg1 = new Vulnerability("abc", "1.4.3", 1, 2, 1, 1, "high", "2.3.1", dummyRange); + let golangVulnerabilityAggregator = new GolangVulnerabilityAggregator(); + var pckg = golangVulnerabilityAggregator.aggregate(pckg1); + + let pckg2 = new Vulnerability("abc/pck1", "1.4.3", 1, 3, 2, 2, "low", "3.3.1", dummyRange); + pckg = golangVulnerabilityAggregator.aggregate(pckg2); + + const msg = "abc: 1.4.3\nNumber of packages: 2\nKnown security vulnerability: 5\nSecurity advisory: 3\nExploits: 3\nHighest severity: high\nRecommendation: 3.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + // Golang should aggregate both data togather. + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test golang aggregator with empty old rec version', async () => { + let pckg1 = new Vulnerability("abc", "1.4.3", 1, 2, 1, 1, "low", "", dummyRange); + let golangVulnerabilityAggregator = new GolangVulnerabilityAggregator(); + var pckg = golangVulnerabilityAggregator.aggregate(pckg1); + + let pckg2 = new Vulnerability("abc/pck1", "1.4.3", 1, 3, 2, 2, "low", "3.3.1", dummyRange); + pckg = golangVulnerabilityAggregator.aggregate(pckg2); + + const msg = "abc: 1.4.3\nNumber of packages: 2\nKnown security vulnerability: 5\nSecurity advisory: 3\nExploits: 3\nHighest severity: low\nRecommendation: 3.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + // Golang should aggregate both data togather. + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test golang aggregator with null old rec version', async () => { + let pckg1 = new Vulnerability("abc", "1.4.3", 1, 2, 1, 1, "medium", null, dummyRange); + let golangVulnerabilityAggregator = new GolangVulnerabilityAggregator(); + var pckg = golangVulnerabilityAggregator.aggregate(pckg1); + + let pckg2 = new Vulnerability("abc/pck1", "1.4.3", 1, 3, 2, 2, "low", "3.3.1", dummyRange); + pckg = golangVulnerabilityAggregator.aggregate(pckg2); + + const msg = "abc: 1.4.3\nNumber of packages: 2\nKnown security vulnerability: 5\nSecurity advisory: 3\nExploits: 3\nHighest severity: medium\nRecommendation: 3.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + // Golang should aggregate both data togather. + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test golang aggregator for vulnerability and module response out of order', async () => { + let pckg1 = new Vulnerability("abc/pck3", "1.4.3", 1, 2, 1, 1, "high", "2.3.1", dummyRange); + let golangVulnerabilityAggregator = new GolangVulnerabilityAggregator(); + var pckg = golangVulnerabilityAggregator.aggregate(pckg1); + + let pckg2 = new Vulnerability("abc", "1.4.3", 1, 3, 2, 2, "critical", "3.3.1", dummyRange); + pckg = golangVulnerabilityAggregator.aggregate(pckg2); + + const msg = "abc: 1.4.3\nNumber of packages: 2\nKnown security vulnerability: 5\nSecurity advisory: 3\nExploits: 3\nHighest severity: critical\nRecommendation: 3.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + // Golang should aggregate both data togather. + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test golang aggregator with first package has null values', async () => { + let pckg1 = new Vulnerability("abc", "1.4.3", 1, null, null, null, "high", null, dummyRange); + let golangVulnerabilityAggregator = new GolangVulnerabilityAggregator(); + var pckg = golangVulnerabilityAggregator.aggregate(pckg1); + + let pckg2 = new Vulnerability("abc/pck1", "1.4.3", 1, 3, 2, 2, "low", "1.6.1", dummyRange); + pckg = golangVulnerabilityAggregator.aggregate(pckg2); + + const msg = "abc: 1.4.3\nNumber of packages: 2\nKnown security vulnerability: 3\nSecurity advisory: 2\nExploits: 2\nHighest severity: high\nRecommendation: 1.6.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + // Golang should aggregate both data togather. + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test golang aggregator with second package has null values', async () => { + let pckg1 = new Vulnerability("abc", "1.4.3", 1, 3, 2, 2, "low", "1.6.1", dummyRange); + let golangVulnerabilityAggregator = new GolangVulnerabilityAggregator(); + var pckg = golangVulnerabilityAggregator.aggregate(pckg1); + + let pckg2 = new Vulnerability("abc/pck1", "1.4.3", 1, null, null, null, "high", null, dummyRange); + pckg = golangVulnerabilityAggregator.aggregate(pckg2); + + const msg = "abc: 1.4.3\nNumber of packages: 2\nKnown security vulnerability: 3\nSecurity advisory: 2\nExploits: 2\nHighest severity: high\nRecommendation: 1.6.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + // Golang should aggregate both data togather. + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + +}); diff --git a/test/consumer.test.ts b/test/consumer.test.ts index dd95e10d..0ac5d7ca 100644 --- a/test/consumer.test.ts +++ b/test/consumer.test.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; -import { SecurityEngine, DiagnosticsPipeline, codeActionsMap } from '../src/consumers'; +import { SecurityEngine, DiagnosticsPipeline } from '../src/consumers'; +import { NoopVulnerabilityAggregator, GolangVulnerabilityAggregator } from '../src/aggregators'; const config = {}; const diagnosticFilePath = "a/b/c/d"; @@ -25,6 +26,7 @@ describe('Response consumer test', () => { it('Consume response for free-users', () => { let DiagnosticsEngines = [SecurityEngine]; let diagnostics = []; + let packageAggregator = new NoopVulnerabilityAggregator(); const response = { "package_unknown": false, "package": "abc", @@ -47,7 +49,7 @@ describe('Response consumer test', () => { "security_advisory_count": 1 }; - let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, diagnosticFilePath); + let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, packageAggregator, diagnosticFilePath); pipeline.run(response); const secEng = pipeline.items[0] as SecurityEngine; const msg = "abc: 1.2.3\nKnown security vulnerability: 1\nSecurity advisory: 1\nExploits: unavailable\nHighest severity: critical\nRecommendation: 2.3.4"; @@ -62,6 +64,7 @@ describe('Response consumer test', () => { it('Consume response for registered-users', () => { let DiagnosticsEngines = [SecurityEngine]; let diagnostics = []; + let packageAggregator = new NoopVulnerabilityAggregator(); const response = { "package_unknown": false, "package": "abc", @@ -85,7 +88,7 @@ describe('Response consumer test', () => { "exploitable_vulnerabilities_count": 1 }; - let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, diagnosticFilePath); + let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, packageAggregator, diagnosticFilePath); pipeline.run(response); const secEng = pipeline.items[0] as SecurityEngine; const msg = "abc: 1.2.3\nKnown security vulnerability: 1\nSecurity advisory: 1\nExploits: 1\nHighest severity: critical\nRecommendation: 2.3.4"; @@ -97,9 +100,79 @@ describe('Response consumer test', () => { expect(secEng.exploitCount).equal(1); }); + it('Consume response for multiple packages', () => { + let DiagnosticsEngines = [SecurityEngine]; + let diagnostics = []; + let packageAggregator = new GolangVulnerabilityAggregator(); + var response = { + "package_unknown": false, + "package": "github.com/abc", + "version": "1.2.3", + "recommended_versions": "2.3.4", + "registration_link": "https://abc.io/login", + "vulnerability": [ + { + "id": "ABC-VULN", + "cvss": "9.8", + "is_private": true, + "cwes": [ + "CWE-79" + ] + } + ], + "message": "github.com/abc - 1.2.3 has 1 known security vulnerability and 1 security advisory with 1 exploitable vulnerability. Recommendation: use version 2.3.4. ", + "highest_severity": "critical", + "known_security_vulnerability_count": 1, + "security_advisory_count": 1, + "exploitable_vulnerabilities_count": 1 + }; + + let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, packageAggregator, diagnosticFilePath); + pipeline.run(response); + var secEng = pipeline.items[0] as SecurityEngine; + var msg = "github.com/abc: 1.2.3\nNumber of packages: 1\nKnown security vulnerability: 1\nSecurity advisory: 1\nExploits: 1\nHighest severity: critical\nRecommendation: 2.3.4"; + + expect(diagnostics.length).equal(1); + expect(diagnostics[0].message).equal(msg); + expect(secEng.vulnerabilityCount).equal(1); + expect(secEng.advisoryCount).equal(1); + expect(secEng.exploitCount).equal(1); + + response = { + "package_unknown": false, + "package": "github.com/abc/pkg/auth", + "version": "1.2.3", + "recommended_versions": "2.6.4", + "registration_link": "https://abc.io/login", + "vulnerability": [ + { + "id": "ABC-VULN", + "cvss": "9.8", + "is_private": true, + "cwes": [ + "CWE-79" + ] + } + ], + "message": "github.com/abc/pkg/auth - 1.2.3 has 1 known security vulnerability and 1 security advisory with 1 exploitable vulnerability. Recommendation: use version 2.3.4. ", + "highest_severity": "high", + "known_security_vulnerability_count": 3, + "security_advisory_count": 2, + "exploitable_vulnerabilities_count": 1 + }; + + pipeline.run(response); + secEng = pipeline.items[0] as SecurityEngine; + msg = "github.com/abc: 1.2.3\nNumber of packages: 2\nKnown security vulnerability: 4\nSecurity advisory: 3\nExploits: 2\nHighest severity: critical\nRecommendation: 2.6.4"; + + expect(diagnostics.length).equal(1); + expect(diagnostics[0].message).equal(msg); + }); + it('Consume response for free-users with only security advisories', () => { let DiagnosticsEngines = [SecurityEngine]; let diagnostics = []; + let packageAggregator = new NoopVulnerabilityAggregator(); const response = { "package_unknown": false, "package": "abc", @@ -122,7 +195,7 @@ describe('Response consumer test', () => { "security_advisory_count": 1 }; - let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, diagnosticFilePath); + let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, packageAggregator, diagnosticFilePath); pipeline.run(response); const secEng = pipeline.items[0] as SecurityEngine; const msg = "abc: 1.2.3\nKnown security vulnerability: 0\nSecurity advisory: 1\nExploits: unavailable\nHighest severity: critical\nRecommendation: N/A"; @@ -137,6 +210,7 @@ describe('Response consumer test', () => { it('Consume response without vulnerability', () => { let DiagnosticsEngines = [SecurityEngine]; let diagnostics = []; + let packageAggregator = new NoopVulnerabilityAggregator(); const response = { "package": "lodash", "version": "4.17.20", @@ -144,7 +218,7 @@ describe('Response consumer test', () => { "recommendation": {} } - let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, diagnosticFilePath); + let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, packageAggregator, diagnosticFilePath); pipeline.run(response); const secEng = pipeline.items[0] as SecurityEngine; @@ -157,6 +231,7 @@ describe('Response consumer test', () => { it('Consume invalid response', () => { let DiagnosticsEngines = [SecurityEngine]; let diagnostics = []; + let packageAggregator = new NoopVulnerabilityAggregator(); const response = { "package_unknown": false, "package": "abc", @@ -170,9 +245,8 @@ describe('Response consumer test', () => { "security_advisory_count": 1 }; - let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, diagnosticFilePath); + let pipeline = new DiagnosticsPipeline(DiagnosticsEngines, dependency, config, diagnostics, packageAggregator, diagnosticFilePath); pipeline.run(response); - const secEng = pipeline.items[0] as SecurityEngine; expect(diagnostics.length).equal(0); }); diff --git a/test/gomod.collector.test.ts b/test/gomod.collector.test.ts index 09c94fd6..dced7347 100644 --- a/test/gomod.collector.test.ts +++ b/test/gomod.collector.test.ts @@ -1,11 +1,14 @@ import { expect } from 'chai'; import { GomodDependencyCollector } from '../src/collector'; +const fake = require('fake-exec'); describe('Golang go.mod parser test', () => { - const collector:GomodDependencyCollector = new GomodDependencyCollector(); + const fakeSourceRoot = "/tmp/fake/path/to/goproject/source"; + const collector: GomodDependencyCollector = new GomodDependencyCollector(fakeSourceRoot); - it('tests valid go.mod', async () => { - const deps = await collector.collect(` + it('tests valid go.mod', async () => { + fake(`go list -f '{{ join .Imports "\\n" }}' ./...`, ""); + const deps = await collector.collect(` module github.com/alecthomas/kingpin require ( github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf @@ -15,27 +18,28 @@ describe('Golang go.mod parser test', () => { ) go 1.13 `); - expect(deps.length).equal(4); - expect(deps[0]).is.eql({ - name: {value: 'github.com/alecthomas/units', position: {line: 0, column: 0}}, - version: {value: 'v0.0.0-20151022065526-2efee857e7cf', position: {line: 4, column: 41}}, - }); - expect(deps[1]).is.eql({ - name: {value: 'github.com/davecgh/go-spew', position: {line: 0, column: 0}}, - version: {value: 'v1.1.1', position: {line: 5, column: 40}}, - }); - expect(deps[2]).is.eql({ - name: {value: 'github.com/pmezard/go-difflib', position: {line: 0, column: 0}}, - version: {value: 'v1.0.0', position: {line: 6, column: 43}}, - }); - expect(deps[3]).is.eql({ - name: {value: 'github.com/stretchr/testify', position: {line: 0, column: 0}}, - version: {value: 'v1.2.2', position: {line: 7, column: 41}}, - }); - }); - - it('tests go.mod with comments', async () => { - const deps = await collector.collect(`// This is start point. + expect(deps.length).equal(4); + expect(deps[0]).is.eql({ + name: { value: 'github.com/alecthomas/units', position: { line: 0, column: 0 } }, + version: { value: 'v0.0.0-20151022065526-2efee857e7cf', position: { line: 4, column: 41 } }, + }); + expect(deps[1]).is.eql({ + name: { value: 'github.com/davecgh/go-spew', position: { line: 0, column: 0 } }, + version: { value: 'v1.1.1', position: { line: 5, column: 40 } }, + }); + expect(deps[2]).is.eql({ + name: { value: 'github.com/pmezard/go-difflib', position: { line: 0, column: 0 } }, + version: { value: 'v1.0.0', position: { line: 6, column: 43 } }, + }); + expect(deps[3]).is.eql({ + name: { value: 'github.com/stretchr/testify', position: { line: 0, column: 0 } }, + version: { value: 'v1.2.2', position: { line: 7, column: 41 } }, + }); + }); + + it('tests go.mod with comments', async () => { + fake(`go list -f '{{ join .Imports "\\n" }}' ./...`, ""); + const deps = await collector.collect(`// This is start point. module github.com/alecthomas/kingpin require ( github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // Valid data before this. @@ -46,23 +50,24 @@ describe('Golang go.mod parser test', () => { go 1.13 // Final notes. `); - expect(deps.length).equal(3); - expect(deps[0]).is.eql({ - name: {value: 'github.com/alecthomas/units', position: {line: 0, column: 0}}, - version: {value: 'v0.0.0-20151022065526-2efee857e7cf', position: {line: 4, column: 41}}, - }); - expect(deps[1]).is.eql({ - name: {value: 'github.com/pmezard/go-difflib', position: {line: 0, column: 0}}, - version: {value: 'v1.0.0', position: {line: 6, column: 43}}, - }); - expect(deps[2]).is.eql({ - name: {value: 'github.com/stretchr/testify', position: {line: 0, column: 0}}, - version: {value: 'v1.2.2', position: {line: 7, column: 41}}, - }); - }); - - it('tests empty lines in go.mod', async () => { - const deps = await collector.collect(` + expect(deps.length).equal(3); + expect(deps[0]).is.eql({ + name: { value: 'github.com/alecthomas/units', position: { line: 0, column: 0 } }, + version: { value: 'v0.0.0-20151022065526-2efee857e7cf', position: { line: 4, column: 41 } }, + }); + expect(deps[1]).is.eql({ + name: { value: 'github.com/pmezard/go-difflib', position: { line: 0, column: 0 } }, + version: { value: 'v1.0.0', position: { line: 6, column: 43 } }, + }); + expect(deps[2]).is.eql({ + name: { value: 'github.com/stretchr/testify', position: { line: 0, column: 0 } }, + version: { value: 'v1.2.2', position: { line: 7, column: 41 } }, + }); + }); + + it('tests empty lines in go.mod', async () => { + fake(`go list -f '{{ join .Imports "\\n" }}' ./...`, ""); + const deps = await collector.collect(` module github.com/alecthomas/kingpin require ( @@ -75,49 +80,51 @@ describe('Golang go.mod parser test', () => { go 1.13 `); - expect(deps.length).equal(2); - expect(deps[0]).is.eql({ - name: {value: 'github.com/alecthomas/units', position: {line: 0, column: 0}}, - version: {value: 'v0.0.0-20151022065526-2efee857e7cf', position: {line: 6, column: 41}}, - }); - expect(deps[1]).is.eql({ - name: {value: 'github.com/stretchr/testify', position: {line: 0, column: 0}}, - version: {value: 'v1.2.2', position: {line: 8, column: 41}}, - }); - }); - - it('tests deps with spaces before and after comparators', async () => { - const deps = await collector.collect(` + expect(deps.length).equal(2); + expect(deps[0]).is.eql({ + name: { value: 'github.com/alecthomas/units', position: { line: 0, column: 0 } }, + version: { value: 'v0.0.0-20151022065526-2efee857e7cf', position: { line: 6, column: 41 } }, + }); + expect(deps[1]).is.eql({ + name: { value: 'github.com/stretchr/testify', position: { line: 0, column: 0 } }, + version: { value: 'v1.2.2', position: { line: 8, column: 41 } }, + }); + }); + + it('tests deps with spaces before and after comparators', async () => { + fake(`go list -f '{{ join .Imports "\\n" }}' ./...`, ""); + const deps = await collector.collect(` module github.com/alecthomas/kingpin require ( github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.2.2 + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.2.2 ) go 1.13 `); - expect(deps.length).equal(4); - expect(deps[0]).is.eql({ - name: {value: 'github.com/alecthomas/units', position: {line: 0, column: 0}}, - version: {value: 'v0.0.0-20151022065526-2efee857e7cf', position: {line: 4, column: 44}}, - }); - expect(deps[1]).is.eql({ - name: {value: 'github.com/davecgh/go-spew', position: {line: 0, column: 0}}, - version: {value: 'v1.1.1', position: {line: 5, column: 49}}, - }); - expect(deps[2]).is.eql({ - name: {value: 'github.com/pmezard/go-difflib', position: {line: 0, column: 0}}, - version: {value: 'v1.0.0', position: {line: 6, column: 39}}, - }); - expect(deps[3]).is.eql({ - name: {value: 'github.com/stretchr/testify', position: {line: 0, column: 0}}, - version: {value: 'v1.2.2', position: {line: 7, column: 37}}, - }); - }); - - it('tests alpha beta and extra for version in go.mod', async () => { - const deps = await collector.collect(` + expect(deps.length).equal(4); + expect(deps[0]).is.eql({ + name: { value: 'github.com/alecthomas/units', position: { line: 0, column: 0 } }, + version: { value: 'v0.0.0-20151022065526-2efee857e7cf', position: { line: 4, column: 44 } }, + }); + expect(deps[1]).is.eql({ + name: { value: 'github.com/davecgh/go-spew', position: { line: 0, column: 0 } }, + version: { value: 'v1.1.1', position: { line: 5, column: 49 } }, + }); + expect(deps[2]).is.eql({ + name: { value: 'github.com/pmezard/go-difflib', position: { line: 0, column: 0 } }, + version: { value: 'v1.0.0', position: { line: 6, column: 51 } }, + }); + expect(deps[3]).is.eql({ + name: { value: 'github.com/stretchr/testify', position: { line: 0, column: 0 } }, + version: { value: 'v1.2.2', position: { line: 7, column: 45 } }, + }); + }); + + it('tests alpha beta and extra for version in go.mod', async () => { + fake(`go list -f '{{ join .Imports "\\n" }}' ./...`, ""); + const deps = await collector.collect(` module github.com/alecthomas/kingpin require ( @@ -133,43 +140,44 @@ describe('Golang go.mod parser test', () => { go 1.13 `); - expect(deps.length).equal(8); - expect(deps[0]).is.eql({ - name: {value: 'github.com/alecthomas/units', position: {line: 0, column: 0}}, - version: {value: 'v0.1.3-alpha', position: {line: 5, column: 39}}, - }); - expect(deps[1]).is.eql({ - name: {value: 'github.com/pierrec/lz4', position: {line: 0, column: 0}}, - version: {value: 'v2.5.2-alpha+incompatible', position: {line: 6, column: 34}}, - }); - expect(deps[2]).is.eql({ - name: {value: 'github.com/davecgh/go-spew', position: {line: 0, column: 0}}, - version: {value: 'v1.1.1+incompatible', position: {line: 7, column: 38}}, - }); - expect(deps[3]).is.eql({ - name: {value: 'github.com/pmezard/go-difflib', position: {line: 0, column: 0}}, - version: {value: 'v1.3.0+version', position: {line: 8, column: 41}}, - }); - expect(deps[4]).is.eql({ - name: {value: 'github.com/stretchr/testify', position: {line: 0, column: 0}}, - version: {value: 'v1.2.2+incompatible-version', position: {line: 9, column: 39}}, - }); - expect(deps[5]).is.eql({ - name: {value: 'github.com/regen-network/protobuf', position: {line: 0, column: 0}}, - version: {value: 'v1.3.2-alpha.regen.4', position: {line: 10, column: 45}}, - }); - expect(deps[6]).is.eql({ - name: {value: 'github.com/vmihailenco/msgpack/v5', position: {line: 0, column: 0}}, - version: {value: 'v5.0.0-beta.1', position: {line: 11, column: 45}}, - }); - expect(deps[7]).is.eql({ - name: {value: 'github.com/btcsuite/btcd', position: {line: 0, column: 0}}, - version: {value: 'v0.20.1-beta', position: {line: 12, column: 36}}, - }); - }); - - it('tests replace statements in go.mod', async () => { - const deps = await collector.collect(` + expect(deps.length).equal(8); + expect(deps[0]).is.eql({ + name: { value: 'github.com/alecthomas/units', position: { line: 0, column: 0 } }, + version: { value: 'v0.1.3-alpha', position: { line: 5, column: 39 } }, + }); + expect(deps[1]).is.eql({ + name: { value: 'github.com/pierrec/lz4', position: { line: 0, column: 0 } }, + version: { value: 'v2.5.2-alpha+incompatible', position: { line: 6, column: 34 } }, + }); + expect(deps[2]).is.eql({ + name: { value: 'github.com/davecgh/go-spew', position: { line: 0, column: 0 } }, + version: { value: 'v1.1.1+incompatible', position: { line: 7, column: 38 } }, + }); + expect(deps[3]).is.eql({ + name: { value: 'github.com/pmezard/go-difflib', position: { line: 0, column: 0 } }, + version: { value: 'v1.3.0+version', position: { line: 8, column: 41 } }, + }); + expect(deps[4]).is.eql({ + name: { value: 'github.com/stretchr/testify', position: { line: 0, column: 0 } }, + version: { value: 'v1.2.2+incompatible-version', position: { line: 9, column: 39 } }, + }); + expect(deps[5]).is.eql({ + name: { value: 'github.com/regen-network/protobuf', position: { line: 0, column: 0 } }, + version: { value: 'v1.3.2-alpha.regen.4', position: { line: 10, column: 45 } }, + }); + expect(deps[6]).is.eql({ + name: { value: 'github.com/vmihailenco/msgpack/v5', position: { line: 0, column: 0 } }, + version: { value: 'v5.0.0-beta.1', position: { line: 11, column: 45 } }, + }); + expect(deps[7]).is.eql({ + name: { value: 'github.com/btcsuite/btcd', position: { line: 0, column: 0 } }, + version: { value: 'v0.20.1-beta', position: { line: 12, column: 36 } }, + }); + }); + + it('tests replace statements in go.mod', async () => { + fake(`go list -f '{{ join .Imports "\\n" }}' ./...`, ""); + const deps = await collector.collect(` module github.com/alecthomas/kingpin go 1.13 require ( @@ -182,19 +190,20 @@ describe('Golang go.mod parser test', () => { github.com/pierrec/lz4 => github.com/pierrec/lz4 v3.4.2 // Required by prometheus-operator ) `); - expect(deps.length).equal(2); - expect(deps[0]).is.eql({ - name: {value: 'github.com/alecthomas/units', position: {line: 0, column: 0}}, - version: {value: 'v0.1.3-alpha', position: {line: 5, column: 39}}, - }); - expect(deps[1]).is.eql({ - name: {value: 'github.com/pierrec/lz4', position: {line: 0, column: 0}}, - version: {value: 'v2.5.2-alpha+incompatible', position: {line: 6, column: 34}}, - }); - }); - - it('tests single line replace statement in go.mod', async () => { - const deps = await collector.collect(` + expect(deps.length).equal(2); + expect(deps[0]).is.eql({ + name: { value: 'github.com/alecthomas/units', position: { line: 0, column: 0 } }, + version: { value: 'v0.1.3-alpha', position: { line: 5, column: 39 } }, + }); + expect(deps[1]).is.eql({ + name: { value: 'github.com/pierrec/lz4', position: { line: 0, column: 0 } }, + version: { value: 'v2.5.2-alpha+incompatible', position: { line: 6, column: 34 } }, + }); + }); + + it('tests single line replace statement in go.mod', async () => { + fake(`go list -f '{{ join .Imports "\\n" }}' ./...`, ""); + const deps = await collector.collect(` module github.com/alecthomas/kingpin go 1.13 require ( @@ -204,45 +213,43 @@ describe('Golang go.mod parser test', () => { replace github.com/alecthomas/units => github.com/test-user/units v13.3.2 `); - expect(deps.length).equal(2); - expect(deps[0]).is.eql({ - name: {value: 'github.com/alecthomas/units', position: {line: 0, column: 0}}, - version: {value: 'v0.1.3-alpha', position: {line: 5, column: 39}}, - }); - expect(deps[1]).is.eql({ - name: {value: 'github.com/pierrec/lz4', position: {line: 0, column: 0}}, - version: {value: 'v2.5.2-alpha+incompatible', position: {line: 6, column: 34}}, - }); - }); - - it('test single line require statement in go.mod', async () => { - const deps = await collector.collect(` - module github.com/alecthomas/kingpin - go 1.13 - require github.com/alecthomas/units v0.1.3-alpha - require github.com/pierrec/lz4 v2.5.2 - `); - expect(deps.length).equal(2); - expect(deps[0]).is.eql({ - name: {value: 'github.com/alecthomas/units', position: {line: 0, column: 0}}, - version: {value: 'v0.1.3-alpha', position: {line: 4, column: 45}}, - }); - expect(deps[1]).is.eql({ - name: {value: 'github.com/pierrec/lz4', position: {line: 0, column: 0}}, - version: {value: 'v2.5.2', position: {line: 5, column: 33}}, - }); - }); - - it('test single require statement with braces in go.mod', async () => { - const deps = await collector.collect(` - module github.com/alecthomas/kingpin - go 1.13 - require ( github.com/alecthomas/units v0.1.3 ) - `); - expect(deps.length).equal(1); - expect(deps[0]).is.eql({ - name: {value: 'github.com/alecthomas/units', position: {line: 0, column: 0}}, - version: {value: 'v0.1.3', position: {line: 4, column: 47}}, - }); + expect(deps.length).equal(2); + expect(deps[0]).is.eql({ + name: { value: 'github.com/alecthomas/units', position: { line: 0, column: 0 } }, + version: { value: 'v0.1.3-alpha', position: { line: 5, column: 39 } }, + }); + expect(deps[1]).is.eql({ + name: { value: 'github.com/pierrec/lz4', position: { line: 0, column: 0 } }, + version: { value: 'v2.5.2-alpha+incompatible', position: { line: 6, column: 34 } }, + }); + }); + + it('tests go.mod with a module in import', async () => { + fake(`go list -f '{{ join .Imports "\\n" }}' ./...`, `fmt +github.com/google/go-cmp/cmp +fmt +github.com/google/go-cmp/cmp +github.com/google/go-cmp/cmp/cmpopts`); + + const deps = await collector.collect(` + module test/data/sample1 + + go 1.15 + + require github.com/google/go-cmp v0.5.2 + `); + expect(deps.length).equal(3); + expect(deps[0]).is.eql({ + name: { value: 'github.com/google/go-cmp', position: { line: 0, column: 0 } }, + version: { value: 'v0.5.2', position: { line: 6, column: 40 } }, + }); + expect(deps[1]).is.eql({ + name: { value: 'github.com/google/go-cmp/cmp', position: { line: 0, column: 0 } }, + version: { value: 'v0.5.2', position: { line: 6, column: 40 } }, + }); + expect(deps[2]).is.eql({ + name: { value: 'github.com/google/go-cmp/cmp/cmpopts', position: { line: 0, column: 0 } }, + version: { value: 'v0.5.2', position: { line: 6, column: 40 } }, }); + }); }); diff --git a/test/npm.collector.test.ts b/test/npm.collector.test.ts index ca6a70e7..4e9dc3da 100644 --- a/test/npm.collector.test.ts +++ b/test/npm.collector.test.ts @@ -18,7 +18,6 @@ describe('npm package.json parser test', () => { "dependencies": {} } `); - console.log(deps); expect(deps.length).equal(0); }); diff --git a/test/vulnerability.test.ts b/test/vulnerability.test.ts new file mode 100644 index 00000000..eaf62a8c --- /dev/null +++ b/test/vulnerability.test.ts @@ -0,0 +1,103 @@ +import { expect } from 'chai'; +import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver'; +import { Vulnerability } from '../src/vulnerability'; + +describe('Vulnerability tests', () => { + const dummyRange: Range = { + start: { + line: 3, + character: 4 + }, + end: { + line: 3, + character: 10 + } + }; + + it('Test vulnerability with minimal fields', async () => { + let pckg = new Vulnerability("abc", "1.4.3", null, null, null, null, "low", null, dummyRange); + + const msg = "abc: 1.4.3\nKnown security vulnerability: 0\nSecurity advisory: 0\nExploits: unavailable\nHighest severity: low\nRecommendation: N/A"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test vulnerability with vulnerability', async () => { + let pckg = new Vulnerability("abc", "1.4.3", 1, 2, 1, 1, "high", "2.3.1", dummyRange); + + const msg = "abc: 1.4.3\nKnown security vulnerability: 2\nSecurity advisory: 1\nExploits: 1\nHighest severity: high\nRecommendation: 2.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test vulnerability without vulnerability', async () => { + let pckg = new Vulnerability("abc", "1.4.3", 1, 0, 1, 1, "high", "2.3.1", dummyRange); + + const msg = "abc: 1.4.3\nKnown security vulnerability: 0\nSecurity advisory: 1\nExploits: 1\nHighest severity: high\nRecommendation: 2.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Information, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test vulnerability without vulnerability but without advisory', async () => { + let pckg = new Vulnerability("abc", "1.4.3", 1, 0, 0, 1, "high", "2.3.1", dummyRange); + + const msg = "abc: 1.4.3\nKnown security vulnerability: 0\nSecurity advisory: 0\nExploits: 1\nHighest severity: high\nRecommendation: 2.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test vulnerability for golang ecosystem', async () => { + let pckg = new Vulnerability("abc", "1.4.3", 1, 2, 1, 1, "high", "2.3.1", dummyRange); + pckg.ecosystem = "golang"; + + const msg = "abc: 1.4.3\nNumber of packages: 1\nKnown security vulnerability: 2\nSecurity advisory: 1\nExploits: 1\nHighest severity: high\nRecommendation: 2.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + + it('Test vulnerability for golang ecosystem with multiple vulnerabilitys', async () => { + let pckg = new Vulnerability("abc", "1.4.3", 4, 2, 1, 1, "high", "2.3.1", dummyRange); + pckg.ecosystem = "golang"; + + const msg = "abc: 1.4.3\nNumber of packages: 4\nKnown security vulnerability: 2\nSecurity advisory: 1\nExploits: 1\nHighest severity: high\nRecommendation: 2.3.1"; + let expectedDiagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: dummyRange, + message: msg, + source: '\nDependency Analytics Plugin [Powered by Snyk]', + }; + + expect(pckg.getDiagnostic()).is.eql(expectedDiagnostic); + }); + +});