From 5fda56014c9c58333a3a050df8957dd434444732 Mon Sep 17 00:00:00 2001 From: Jason Luong Date: Mon, 23 Oct 2023 17:15:29 +0100 Subject: [PATCH] refactor: create duplicate snykOss folder --- src/snyk/extension.ts | 2 +- .../vulnerabilityCodeActionProvider.ts | 81 +++++ src/snyk/snykOss/constants/nativeModules.ts | 44 +++ src/snyk/snykOss/editor/editorDecorator.ts | 124 ++++++++ .../vulnerabilityCountHoverProvider.ts | 49 +++ src/snyk/snykOss/messages/error.ts | 3 + src/snyk/snykOss/messages/test.ts | 9 + src/snyk/snykOss/messages/treeView.ts | 11 + .../snykOss/messages/vulnerabilityCount.ts | 19 ++ src/snyk/snykOss/ossResult.ts | 58 ++++ src/snyk/snykOss/services/ossService.ts | 224 +++++++++++++ .../vulnerabilityCount/importedModule.ts | 41 +++ .../ossVulnerabilityCountService.ts | 278 +++++++++++++++++ .../vulnerabilityCount/parsers/babelParser.ts | 158 ++++++++++ .../vulnerabilityCount/parsers/htmlParser.ts | 116 +++++++ .../parsers/moduleParser.ts | 5 + .../parsers/packageJsonParser.ts | 132 ++++++++ .../vulnerabilityCountProvider.ts | 161 ++++++++++ .../views/ossVulnerabilityTreeProvider.ts | 230 ++++++++++++++ .../ossSuggestionWebviewProvider.ts | 210 +++++++++++++ .../suggestion/ossSuggestionWebviewScript.ts | 293 ++++++++++++++++++ src/snyk/snykOss/vulnerabilityCountEmitter.ts | 32 ++ src/snyk/snykOss/watchers/dailyScanJob.ts | 19 ++ .../snykOss/watchers/manifestFileWatcher.ts | 59 ++++ 24 files changed, 2357 insertions(+), 1 deletion(-) create mode 100644 src/snyk/snykOss/codeActions/vulnerabilityCodeActionProvider.ts create mode 100644 src/snyk/snykOss/constants/nativeModules.ts create mode 100644 src/snyk/snykOss/editor/editorDecorator.ts create mode 100644 src/snyk/snykOss/hoverProvider/vulnerabilityCountHoverProvider.ts create mode 100644 src/snyk/snykOss/messages/error.ts create mode 100644 src/snyk/snykOss/messages/test.ts create mode 100644 src/snyk/snykOss/messages/treeView.ts create mode 100644 src/snyk/snykOss/messages/vulnerabilityCount.ts create mode 100644 src/snyk/snykOss/ossResult.ts create mode 100644 src/snyk/snykOss/services/ossService.ts create mode 100644 src/snyk/snykOss/services/vulnerabilityCount/importedModule.ts create mode 100644 src/snyk/snykOss/services/vulnerabilityCount/ossVulnerabilityCountService.ts create mode 100644 src/snyk/snykOss/services/vulnerabilityCount/parsers/babelParser.ts create mode 100644 src/snyk/snykOss/services/vulnerabilityCount/parsers/htmlParser.ts create mode 100644 src/snyk/snykOss/services/vulnerabilityCount/parsers/moduleParser.ts create mode 100644 src/snyk/snykOss/services/vulnerabilityCount/parsers/packageJsonParser.ts create mode 100644 src/snyk/snykOss/services/vulnerabilityCount/vulnerabilityCountProvider.ts create mode 100644 src/snyk/snykOss/views/ossVulnerabilityTreeProvider.ts create mode 100644 src/snyk/snykOss/views/suggestion/ossSuggestionWebviewProvider.ts create mode 100644 src/snyk/snykOss/views/suggestion/ossSuggestionWebviewScript.ts create mode 100644 src/snyk/snykOss/vulnerabilityCountEmitter.ts create mode 100644 src/snyk/snykOss/watchers/dailyScanJob.ts create mode 100644 src/snyk/snykOss/watchers/manifestFileWatcher.ts diff --git a/src/snyk/extension.ts b/src/snyk/extension.ts index b6c0a59eb..722069a13 100644 --- a/src/snyk/extension.ts +++ b/src/snyk/extension.ts @@ -21,8 +21,8 @@ import { SNYK_OPEN_BROWSER_COMMAND, SNYK_OPEN_ISSUE_COMMAND, SNYK_OPEN_LOCAL_COMMAND, - SNYK_SET_TOKEN_COMMAND, SNYK_SETTINGS_COMMAND, + SNYK_SET_TOKEN_COMMAND, SNYK_SHOW_LS_OUTPUT_COMMAND, SNYK_SHOW_OUTPUT_COMMAND, SNYK_START_COMMAND, diff --git a/src/snyk/snykOss/codeActions/vulnerabilityCodeActionProvider.ts b/src/snyk/snykOss/codeActions/vulnerabilityCodeActionProvider.ts new file mode 100644 index 000000000..68b1b3535 --- /dev/null +++ b/src/snyk/snykOss/codeActions/vulnerabilityCodeActionProvider.ts @@ -0,0 +1,81 @@ +import { IAnalytics } from '../../common/analytics/itly'; +import { OpenCommandIssueType, OpenIssueCommandArg } from '../../common/commands/types'; +import { SNYK_OPEN_ISSUE_COMMAND } from '../../common/constants/commands'; +import { IDE_NAME } from '../../common/constants/general'; +import { ICodeActionKindAdapter } from '../../common/vscode/codeAction'; +import { + CodeAction, + CodeActionContext, + CodeActionKind, + CodeActionProvider, + Command, + ProviderResult, + Range, + Selection, + TextDocument, +} from '../../common/vscode/types'; +import { DIAGNOSTICS_OSS_COLLECTION_NAME } from '../../snykCode/constants/analysis'; +import { messages } from '../messages/vulnerabilityCount'; +import { isResultCliError } from '../ossResult'; +import { OssService } from '../services/ossService'; +import { ModuleVulnerabilityCountProvider } from '../services/vulnerabilityCount/vulnerabilityCountProvider'; + +export class VulnerabilityCodeActionProvider implements CodeActionProvider { + public codeActionKinds: ReadonlyArray = [this.codeActionKindProvider.getQuickFix()]; + + constructor( + private readonly ossService: OssService, + private readonly vulnerabilityCountProvider: ModuleVulnerabilityCountProvider, + private readonly codeActionKindProvider: ICodeActionKindAdapter, + private readonly analytics: IAnalytics, + ) {} + + async provideCodeActions( + document: TextDocument, + _: Range | Selection, + context: CodeActionContext, + ): Promise> { + const ossDiagnostics = context.diagnostics.filter(d => d.source === DIAGNOSTICS_OSS_COLLECTION_NAME); + if (!ossDiagnostics.length) { + return; + } + + const ossResult = this.ossService.getResultArray(); + if (!ossResult) { + return; + } + + const fileResult = ossResult.find( + res => !isResultCliError(res) && this.vulnerabilityCountProvider.isFilePartOfOssTest(document.fileName, res), + ); + + if (!fileResult || isResultCliError(fileResult)) { + return; + } + + for (const diagnostic of ossDiagnostics) { + const vulnerability = fileResult.vulnerabilities.find(vuln => vuln.id === diagnostic.code); + if (!vulnerability) { + continue; + } + + const command: Command = { + command: SNYK_OPEN_ISSUE_COMMAND, + title: messages.showMostSevereVulnerability, + arguments: [ + { + issueType: OpenCommandIssueType.OssVulnerability, + issue: await this.ossService.getOssIssueCommandArg(vulnerability, fileResult.vulnerabilities), + } as OpenIssueCommandArg, + ], + }; + + this.analytics.logQuickFixIsDisplayed({ + quickFixType: ['Show Most Severe Vulnerability'], + ide: IDE_NAME, + }); + + return [command]; + } + } +} diff --git a/src/snyk/snykOss/constants/nativeModules.ts b/src/snyk/snykOss/constants/nativeModules.ts new file mode 100644 index 000000000..ec5d988e7 --- /dev/null +++ b/src/snyk/snykOss/constants/nativeModules.ts @@ -0,0 +1,44 @@ +export default [ + 'assert', + 'async_hooks', + 'buffer', + 'child_process', + 'cluster', + 'console', + 'constants', + 'crypto', + 'dgram', + 'dns', + 'domain', + 'events', + 'fs', + 'http', + 'http2', + 'https', + 'inspector', + 'internal', + 'module', + 'net', + 'os', + 'path', + 'perf_hooks', + 'process', + 'punycode', + 'querystring', + 'readline', + 'repl', + 'stream', + 'string_decoder', + 'sys', + 'timers', + 'tls', + 'trace_events', + 'tty', + 'url', + 'util', + 'v8', + 'vm', + 'wasi', + 'worker_threads', + 'zlib', +]; diff --git a/src/snyk/snykOss/editor/editorDecorator.ts b/src/snyk/snykOss/editor/editorDecorator.ts new file mode 100644 index 000000000..398a5d888 --- /dev/null +++ b/src/snyk/snykOss/editor/editorDecorator.ts @@ -0,0 +1,124 @@ +import _ from 'lodash'; +import { getRenderOptions, LineDecorations, updateDecorations } from '../../common/editor/editorDecorator'; +import { IVSCodeLanguages } from '../../common/vscode/languages'; +import { IThemeColorAdapter } from '../../common/vscode/theme'; +import { TextEditorDecorationType } from '../../common/vscode/types'; +import { IVSCodeWindow } from '../../common/vscode/window'; +import { messages } from '../messages/vulnerabilityCount'; +import { ImportedModule, ModuleVulnerabilityCount } from '../services/vulnerabilityCount/importedModule'; + +export class EditorDecorator { + private readonly decorationType: TextEditorDecorationType; + private readonly fileDecorationMap: Map; + private readonly editorLastCharacterIndex = Number.MAX_SAFE_INTEGER; + + private updateTimeout: NodeJS.Timer | undefined = undefined; + + constructor( + private readonly window: IVSCodeWindow, + private readonly languages: IVSCodeLanguages, + private readonly themeColorAdapter: IThemeColorAdapter, + ) { + this.fileDecorationMap = new Map(); + this.decorationType = this.window.createTextEditorDecorationType({ + after: { margin: '0 0 0 1rem' }, + }); + } + + get fileDecorations(): ReadonlyMap { + return this.fileDecorationMap; + } + + resetDecorations(filePath: string): void { + const decorations = this.fileDecorationMap.get(filePath); + if (!decorations) { + return; + } + + const emptyDecorations = decorations.map(d => ({ + ...d, + renderOptions: getRenderOptions('', this.themeColorAdapter), + })); + this.fileDecorationMap.set(filePath, emptyDecorations); + this.triggerUpdateDecorations(filePath); + } + + setScanStartDecorations(filePath: string, modules: ImportedModule[]): void { + const lineDecorations: LineDecorations = []; + + for (const module of modules) { + if (module.line == null) { + continue; + } + + lineDecorations[module.line] = { + range: this.languages.createRange( + module.line - 1, + this.editorLastCharacterIndex, + module.line - 1, + this.editorLastCharacterIndex, + ), + renderOptions: getRenderOptions(messages.fetchingVulnerabilities, this.themeColorAdapter), + }; + } + + if (!lineDecorations.length) { + // return early when no decorations have been created + return; + } + + this.fileDecorationMap.set(filePath, lineDecorations); + this.triggerUpdateDecorations(filePath); + } + + setScanDoneDecorations(filePath: string, vulnerabilityCounts: ModuleVulnerabilityCount[]): void { + for (const moduleVulnerabilityCount of vulnerabilityCounts) { + this.setScannedDecoration(moduleVulnerabilityCount, false); + } + + this.triggerUpdateDecorations(filePath); + } + + setScannedDecoration(vulnerabilityCount: ModuleVulnerabilityCount, triggerUpdate = true): void { + if (_.isNull(vulnerabilityCount.line)) { + return; + } + + const filePath = vulnerabilityCount.fileName; + + let lineDecorations = this.fileDecorationMap.get(filePath); + if (!lineDecorations) { + lineDecorations = []; + this.fileDecorationMap.set(filePath, lineDecorations); // set map, if no decoration was set before + } + + const text = vulnerabilityCount.count ? messages.decoratorMessage(vulnerabilityCount.count) : ''; + + lineDecorations[vulnerabilityCount.line] = { + range: this.languages.createRange( + vulnerabilityCount.line - 1, + this.editorLastCharacterIndex, + vulnerabilityCount.line - 1, + this.editorLastCharacterIndex, + ), + renderOptions: getRenderOptions(text, this.themeColorAdapter), + }; + + if (triggerUpdate) { + this.triggerUpdateDecorations(filePath, 500); + } + } + + private triggerUpdateDecorations(filePath: string, updateTimeoutInMs = 10): void { + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + this.updateTimeout = undefined; + } + + const lineDecorations = this.fileDecorationMap.get(filePath) || []; + this.updateTimeout = setTimeout( + () => updateDecorations(this.window, filePath, lineDecorations, this.decorationType), + updateTimeoutInMs, + ); + } +} diff --git a/src/snyk/snykOss/hoverProvider/vulnerabilityCountHoverProvider.ts b/src/snyk/snykOss/hoverProvider/vulnerabilityCountHoverProvider.ts new file mode 100644 index 000000000..8fc56f11d --- /dev/null +++ b/src/snyk/snykOss/hoverProvider/vulnerabilityCountHoverProvider.ts @@ -0,0 +1,49 @@ +import { IAnalytics } from '../../common/analytics/itly'; +import { IDE_NAME } from '../../common/constants/general'; +import { SupportedLanguageIds } from '../../common/constants/languageConsts'; +import { IVSCodeLanguages } from '../../common/vscode/languages'; +import { DiagnosticCollection, Disposable, Hover, Position, TextDocument } from '../../common/vscode/types'; +import { IssueUtils } from '../../snykCode/utils/issueUtils'; + +export class VulnerabilityCountHoverProvider implements Disposable { + private hoverProvider: Disposable | undefined; + + constructor(private readonly vscodeLanguages: IVSCodeLanguages, private readonly analytics: IAnalytics) {} + + register(diagnostics: DiagnosticCollection | undefined): Disposable { + const documentFilter = SupportedLanguageIds.map(id => ({ scheme: 'file', language: id })); + + this.hoverProvider = this.vscodeLanguages.registerHoverProvider(documentFilter, { + provideHover: this.getHover(diagnostics), + }); + + return this; + } + + getHover(diagnostics: DiagnosticCollection | undefined) { + return (document: TextDocument, position: Position): Hover | undefined => { + if (!diagnostics || !diagnostics.has(document.uri)) { + return undefined; + } + + const currentFileReviewIssues = diagnostics.get(document.uri); + const issue = IssueUtils.findIssueWithRange(position, currentFileReviewIssues); + if (issue) { + this.logIssueHoverIsDisplayed(); + } + }; + } + + private logIssueHoverIsDisplayed(): void { + this.analytics.logIssueHoverIsDisplayed({ + issueType: 'Open Source Vulnerability', + ide: IDE_NAME, + }); + } + + dispose(): void { + if (this.hoverProvider) { + this.hoverProvider.dispose(); + } + } +} diff --git a/src/snyk/snykOss/messages/error.ts b/src/snyk/snykOss/messages/error.ts new file mode 100644 index 000000000..f8f215e17 --- /dev/null +++ b/src/snyk/snykOss/messages/error.ts @@ -0,0 +1,3 @@ +export const messages = { + suggestionViewShowFailed: 'Failed to show Snyk OSS suggestion view', +}; diff --git a/src/snyk/snykOss/messages/test.ts b/src/snyk/snykOss/messages/test.ts new file mode 100644 index 000000000..d41d6d05e --- /dev/null +++ b/src/snyk/snykOss/messages/test.ts @@ -0,0 +1,9 @@ +export const messages = { + testFailed: 'Open Source Security test failed.', + testStarted: 'Open Source Security test started.', + viewResults: 'View results', + hide: "Don't show again", + + testFailedForPath: (path: string): string => `Open Source Security test failed for "${path}".`, + testFinished: (projectName: string): string => `Open Source Security test finished for "${projectName}".`, +}; diff --git a/src/snyk/snykOss/messages/treeView.ts b/src/snyk/snykOss/messages/treeView.ts new file mode 100644 index 000000000..e01a041bc --- /dev/null +++ b/src/snyk/snykOss/messages/treeView.ts @@ -0,0 +1,11 @@ +export const messages = { + cookingDependencies: 'Scanning...', + + runTest: 'Run scan for Open Source security vulnerabilities.', + noVulnerabilitiesFound: ' ✅ Congrats! Snyk found no vulnerabilities.', + singleVulnerabilityFound: 'Snyk found 1 vulnerability', + vulnerability: 'vulnerability', + vulnerabilities: 'vulnerabilities', + + multipleVulnerabilitiesFound: (issueCount: number): string => `Snyk found ${issueCount} vulnerabilities`, +}; diff --git a/src/snyk/snykOss/messages/vulnerabilityCount.ts b/src/snyk/snykOss/messages/vulnerabilityCount.ts new file mode 100644 index 000000000..f328ebd22 --- /dev/null +++ b/src/snyk/snykOss/messages/vulnerabilityCount.ts @@ -0,0 +1,19 @@ +import { ModuleVulnerabilityCount } from '../services/vulnerabilityCount/importedModule'; + +export const messages = { + fetchingVulnerabilities: 'Fetching vulnerabilities...', + vulnerability: 'vulnerability', + vulnerabilities: 'vulnerabilities', + showMostSevereVulnerability: 'Show the most severe vulnerability (Snyk)', + + decoratorMessage: (vulnerabilityCount: string): string => { + const vulnerabilityCountNumber = Number.parseInt(vulnerabilityCount, 10); + if (isNaN(vulnerabilityCountNumber)) { + return vulnerabilityCount; + } + return `${vulnerabilityCountNumber} ${vulnerabilityCountNumber > 1 ? 'vulnerabilities' : 'vulnerability'}`; + }, + + diagnosticMessagePrefix: (module: ModuleVulnerabilityCount): string => + `Dependency ${module.name}${module.version ? `@${module.version}` : ''} has `, +}; diff --git a/src/snyk/snykOss/ossResult.ts b/src/snyk/snykOss/ossResult.ts new file mode 100644 index 000000000..625019a49 --- /dev/null +++ b/src/snyk/snykOss/ossResult.ts @@ -0,0 +1,58 @@ +import _ from 'lodash'; +import { CliError } from '../cli/services/cliService'; + +export type OssResult = OssFileResult[] | OssFileResult; + +export type OssFileResult = OssResultBody | CliError; + +export type OssResultBody = { + vulnerabilities: OssVulnerability[]; + projectName: string; + displayTargetFile: string; + packageManager: string; + path: string; +}; + +export type OssVulnerability = { + id: string; + license?: string; + identifiers?: Identifiers; + title: string; + description: string; + language: string; + packageManager: string; + packageName: string; + severity: OssSeverity; + name: string; + version: string; + exploit?: string; + + CVSSv3?: string; + cvssScore?: string; + + fixedIn?: Array; + from: Array; + upgradePath: Array; + isPatchable: boolean; + isUpgradable: boolean; +}; + +export type Identifiers = { + CWE: string[]; + CVE: string[]; +}; + +export enum OssSeverity { + Low = 'low', + Medium = 'medium', + High = 'high', + Critical = 'critical', +} + +export function capitalizeOssSeverity(ossSeverity: OssSeverity): Capitalize { + return _.capitalize(ossSeverity) as Capitalize; +} + +export function isResultCliError(fileResult: OssFileResult): fileResult is CliError { + return (fileResult as CliError).error !== undefined; +} diff --git a/src/snyk/snykOss/services/ossService.ts b/src/snyk/snykOss/services/ossService.ts new file mode 100644 index 000000000..0a1cda3d7 --- /dev/null +++ b/src/snyk/snykOss/services/ossService.ts @@ -0,0 +1,224 @@ +import * as marked from 'marked'; +import { Subject } from 'rxjs'; +import { IExtension } from '../../base/modules/interfaces'; +import { CliError, CliService } from '../../cli/services/cliService'; +import { IAnalytics } from '../../common/analytics/itly'; +import { IConfiguration } from '../../common/configuration/configuration'; +import { IWorkspaceTrust } from '../../common/configuration/trustedFolders'; +import { IDE_NAME } from '../../common/constants/general'; +import { ILanguageServer } from '../../common/languageServer/languageServer'; +import { ILog } from '../../common/logger/interfaces'; +import { DownloadService } from '../../common/services/downloadService'; +import { INotificationService } from '../../common/services/notificationService'; +import { IViewManagerService } from '../../common/services/viewManagerService'; +import { IWebViewProvider } from '../../common/views/webviewProvider'; +import { ExtensionContext } from '../../common/vscode/extensionContext'; +import { IVSCodeWorkspace } from '../../common/vscode/workspace'; +import { messages } from '../messages/test'; +import { isResultCliError, OssFileResult, OssResult, OssSeverity, OssVulnerability } from '../ossResult'; +import { OssIssueCommandArg } from '../views/ossVulnerabilityTreeProvider'; +import { DailyScanJob } from '../watchers/dailyScanJob'; +import createManifestFileWatcher from '../watchers/manifestFileWatcher'; + +export class OssService extends CliService { + protected readonly command: string[] = ['test']; + + private isVulnerabilityTreeVisible = false; + + readonly scanFinished$ = new Subject(); + + constructor( + protected readonly extensionContext: ExtensionContext, + protected readonly logger: ILog, + protected readonly config: IConfiguration, + private readonly suggestionProvider: IWebViewProvider, + protected readonly workspace: IVSCodeWorkspace, + private readonly viewManagerService: IViewManagerService, + protected readonly downloadService: DownloadService, + private readonly dailyScanJob: DailyScanJob, + private readonly notificationService: INotificationService, + private readonly analytics: IAnalytics, + protected readonly languageServer: ILanguageServer, + protected readonly workspaceTrust: IWorkspaceTrust, + ) { + super(extensionContext, logger, config, workspace, downloadService, languageServer, workspaceTrust); + } + + public getResultArray = (): ReadonlyArray | undefined => { + if (!this.result) { + return undefined; + } + + return Array.isArray(this.result) ? this.result : [this.result]; + }; + + protected mapToResultType(rawCliResult: string): OssResult { + if (rawCliResult.length == 0) { + throw new Error('CLI returned empty output result.'); + } + + let result: OssResult; + try { + result = JSON.parse(rawCliResult) as OssResult; + } catch (err) { + throw new Error(`Failed to parse JSON result. Unparsed: ${rawCliResult}`); + } + + return result; + } + + protected ensureDependencies(): void { + this.viewManagerService.refreshOssView(); + this.logger.info('Waiting for Open Source scan CLI readiness'); + } + + protected beforeTest(manualTrigger: boolean, reportTriggeredEvent: boolean): void { + this.logger.info(messages.testStarted); + this.viewManagerService.refreshOssView(); + + if (reportTriggeredEvent) { + this.analytics.logAnalysisIsTriggered({ + analysisType: ['Snyk Open Source'], + ide: IDE_NAME, + triggeredByUser: manualTrigger, + }); + } + } + + protected afterTest(result: OssResult | CliError): void { + if (result instanceof CliError) { + this.logger.error(`${messages.testFailed} ${result.error}`); + this.logAnalysisIsReady('Error'); + } else { + this.logOssResult(result); + + if (this.config.shouldAutoScanOss) { + this.dailyScanJob.schedule(); + } + } + + this.scanFinished$.next(); + this.viewManagerService.refreshOssView(); + } + + override handleLsDownloadFailure(): void { + super.handleLsDownloadFailure(); + this.viewManagerService.refreshOssView(); + } + + override handleNoTrustedFolders(): void { + super.handleNoTrustedFolders(); + this.viewManagerService.refreshOssView(); + } + + activateSuggestionProvider(): void { + this.suggestionProvider.activate(); + } + + showSuggestionProvider(vulnerability: OssIssueCommandArg): Promise { + return this.suggestionProvider.showPanel(vulnerability); + } + + activateManifestFileWatcher(extension: IExtension): void { + const manifestWatcher = createManifestFileWatcher(extension, this.workspace, this.config); + this.extensionContext.addDisposables(manifestWatcher); + } + + setVulnerabilityTreeVisibility(visible: boolean): void { + this.isVulnerabilityTreeVisible = visible; + } + + getUniqueVulnerabilities(vulnerabilities: OssVulnerability[]): OssVulnerability[] { + return vulnerabilities.filter((val, i, arr) => arr.findIndex(el => el.id === val.id) == i); + } + + getNewCriticalVulnerabilitiesCount(currentResult: OssResult, otherResult: OssResult): number { + if (Array.isArray(currentResult) && Array.isArray(otherResult)) { + let newVulnerabilityCount = 0; + for (let i = 0; i < otherResult.length; i++) { + newVulnerabilityCount += this.getNewCriticalVulnerabilitiesCount(currentResult[i], otherResult[i]); + } + + return newVulnerabilityCount; + } + + // if only one of results is an array, no count possible + if (Array.isArray(currentResult) || Array.isArray(otherResult)) { + throw new Error('Result types mismatch for new vulnerabilities calculation.'); + } + + if (!currentResult || isResultCliError(currentResult)) { + return 0; + } + + const currentVulnSet = this.getUniqueVulnerabilities(currentResult.vulnerabilities).filter( + v => v.severity === OssSeverity.Critical, + ); + + if (isResultCliError(otherResult)) { + return currentVulnSet.length; + } + + const otherVulnSet = this.getUniqueVulnerabilities(otherResult.vulnerabilities).filter( + v => v.severity === OssSeverity.Critical, + ); + + if (currentVulnSet.length > otherVulnSet.length) { + return currentVulnSet.length - otherVulnSet.length; + } + + return 0; + } + + getOssIssueCommandArg( + vulnerability: OssVulnerability, + allVulnerabilities: OssVulnerability[], + ): Promise { + return new Promise((resolve, reject) => { + const matchingIdVulnerabilities = allVulnerabilities.filter(v => v.id === vulnerability.id); + marked.parse(vulnerability.description, (err, overviewHtml) => { + if (err) { + return reject(err); + } + + return resolve({ + ...vulnerability, + matchingIdVulnerabilities: matchingIdVulnerabilities, + overviewHtml, + }); + }); + }); + } + + private logOssResult(result: OssResult) { + const fileResults = Array.isArray(result) ? result : [result]; + + for (const fileResult of fileResults) { + if (isResultCliError(fileResult)) { + this.logger.error(this.getTestErrorMessage(fileResult)); + this.logAnalysisIsReady('Error'); + } else { + this.logger.info(messages.testFinished(fileResult.projectName)); + this.logAnalysisIsReady('Success'); + } + } + } + + private getTestErrorMessage(fileResult: CliError): string { + let errorMessage: string; + if (fileResult.path) { + errorMessage = `${messages.testFailedForPath(fileResult.path)} ${fileResult.error}`; + } else { + errorMessage = `${messages.testFailed} ${fileResult.error}`; + } + return errorMessage; + } + + private logAnalysisIsReady(result: 'Error' | 'Success'): void { + this.analytics.logAnalysisIsReady({ + ide: IDE_NAME, + analysisType: 'Snyk Open Source', + result, + }); + } +} diff --git a/src/snyk/snykOss/services/vulnerabilityCount/importedModule.ts b/src/snyk/snykOss/services/vulnerabilityCount/importedModule.ts new file mode 100644 index 000000000..0fe3843f2 --- /dev/null +++ b/src/snyk/snykOss/services/vulnerabilityCount/importedModule.ts @@ -0,0 +1,41 @@ +export type ImportedModule = { + fileName: string; + name: string; + line: number | null; + loc: Range | null; + string: string; + version?: string; +}; + +export type Range = { + start: { + line: number; + column: number; + }; + end: { + line: number; + column: number; + }; +}; +export enum ModuleVulnerabilityCountSeverity { + Low = 'low', + Medium = 'medium', + High = 'high', + Critical = 'critical', +} + +export type SeverityCounts = { + [s in ModuleVulnerabilityCountSeverity]: number; +}; + +export type ModuleVulnerabilityCount = { + name: string; + version?: string; + fileName: string; + line: number | null; + range: Range | null; + hasCount: boolean; + count?: string; + severityCounts?: SeverityCounts; + mostSevereVulnerabilityId?: string; +}; diff --git a/src/snyk/snykOss/services/vulnerabilityCount/ossVulnerabilityCountService.ts b/src/snyk/snykOss/services/vulnerabilityCount/ossVulnerabilityCountService.ts new file mode 100644 index 000000000..787d0f36c --- /dev/null +++ b/src/snyk/snykOss/services/vulnerabilityCount/ossVulnerabilityCountService.ts @@ -0,0 +1,278 @@ +import { Subscription } from 'rxjs'; +import { IAnalytics } from '../../../common/analytics/itly'; +import { IConfiguration } from '../../../common/configuration/configuration'; +import { HTML, JAVASCRIPT, PJSON, TYPESCRIPT } from '../../../common/constants/languageConsts'; +import { ILog } from '../../../common/logger/interfaces'; +import { getSupportedLanguage, isValidModuleName } from '../../../common/parsing'; +import { ModuleParserProvider } from '../../../common/services/moduleParserProvider'; +import { Language } from '../../../common/types'; +import { ICodeActionKindAdapter } from '../../../common/vscode/codeAction'; +import { IVSCodeLanguages } from '../../../common/vscode/languages'; +import { Diagnostic, DiagnosticCollection, Disposable, TextDocument } from '../../../common/vscode/types'; +import { IVSCodeWindow } from '../../../common/vscode/window'; +import { IVSCodeWorkspace } from '../../../common/vscode/workspace'; +import { DIAGNOSTICS_OSS_COLLECTION_NAME } from '../../../snykCode/constants/analysis'; +import { VulnerabilityCodeActionProvider } from '../../codeActions/vulnerabilityCodeActionProvider'; +import { EditorDecorator } from '../../editor/editorDecorator'; +import { VulnerabilityCountHoverProvider } from '../../hoverProvider/vulnerabilityCountHoverProvider'; +import { messages } from '../../messages/vulnerabilityCount'; +import { VulnerabilityCountEmitter, VulnerabilityCountEvents } from '../../vulnerabilityCountEmitter'; +import { OssService } from '../ossService'; +import { ImportedModule, ModuleVulnerabilityCount, ModuleVulnerabilityCountSeverity } from './importedModule'; +import { ModuleVulnerabilityCountProvider } from './vulnerabilityCountProvider'; + +export enum SupportedLanguage { + TypeScript, + JavaScript, + HTML, + PJSON, +} + +export class OssVulnerabilityCountService implements Disposable { + protected disposables: Disposable[] = []; + protected ossScanFinishedSubscription: Subscription; + + private fileEmitters = new Map(); + private diagnostics: DiagnosticCollection | undefined; + + constructor( + private readonly workspace: IVSCodeWorkspace, + private readonly window: IVSCodeWindow, + private readonly languages: IVSCodeLanguages, + private readonly vulnerabilityCountProvider: ModuleVulnerabilityCountProvider, + private readonly ossService: OssService, + private readonly logger: ILog, + private readonly editorDecorator: EditorDecorator, + private readonly codeActionKindProvider: ICodeActionKindAdapter, + private readonly analytics: IAnalytics, + private readonly configuration: IConfiguration, + ) {} + + activate(): boolean { + this.disposables.push( + (this.diagnostics = this.languages.createDiagnosticCollection(DIAGNOSTICS_OSS_COLLECTION_NAME)), + this.workspace.onDidChangeTextDocument(ev => { + if (ev.contentChanges.length) { + this.processFile(ev.document); + } + }), + this.window.onDidChangeActiveTextEditor(ev => { + if (ev) { + this.processFile(ev.document); + } + }), + + // register hover provider + new VulnerabilityCountHoverProvider(this.languages, this.analytics).register(this.diagnostics), + ); + + // Subscribe to OSS scan finished updates + this.ossScanFinishedSubscription = this.ossService.scanFinished$.subscribe(() => this.processActiveEditor()); + + [JAVASCRIPT, TYPESCRIPT, PJSON, HTML].forEach(language => { + const provider = new VulnerabilityCodeActionProvider( + this.ossService, + this.vulnerabilityCountProvider, + this.codeActionKindProvider, + this.analytics, + ); + this.disposables.push( + this.languages.registerCodeActionsProvider(language, provider, { + providedCodeActionKinds: provider.codeActionKinds, + }), + ); + }); + + this.processActiveEditor(); + + return true; + } + + processActiveEditor(): void { + const activeEditor = this.window.getActiveTextEditor(); + if (activeEditor) { + this.processFile(activeEditor.document); + } + } + + dispose(): void { + while (this.disposables.length) { + const disposable = this.disposables.pop(); + if (disposable) { + disposable.dispose(); + } + } + + for (const emitter of this.fileEmitters.values()) { + emitter.removeAllListeners(); + } + + this.ossScanFinishedSubscription.unsubscribe(); + } + + processFile(document: TextDocument): boolean { + if (!document) { + return false; + } + + const { fileName, languageId } = document; + const supportedLanguage = getSupportedLanguage(fileName, languageId); + if (supportedLanguage === null || !this.shouldProcessFile(fileName, supportedLanguage)) { + return false; + } + + let emitter = this.fileEmitters.get(fileName); + if (emitter) { + emitter.removeAllListeners(); + this.editorDecorator.resetDecorations(fileName); + } else { + emitter = new VulnerabilityCountEmitter(); + this.fileEmitters.set(fileName, emitter); + } + + emitter.on(VulnerabilityCountEvents.Error, e => { + this.logger.error(`Error counting module vulnerabilities: ${e}`); + this.editorDecorator.resetDecorations(fileName); + }); + + emitter.on(VulnerabilityCountEvents.Start, (modules: ImportedModule[]) => { + this.editorDecorator.setScanStartDecorations(fileName, modules); + }); + emitter.on(VulnerabilityCountEvents.Scanned, (vulnerabilityCount: ModuleVulnerabilityCount) => { + this.editorDecorator.setScannedDecoration(vulnerabilityCount, true); + }); + + emitter.on(VulnerabilityCountEvents.Done, (modules: ModuleVulnerabilityCount[]) => { + this.editorDecorator.setScanDoneDecorations(fileName, modules); + this.updateDiagnostics(document, modules); + }); + + // Start + void this.getImportedModules(fileName, document.getText(), supportedLanguage, emitter); + return true; + } + + private updateDiagnostics(document: TextDocument, modules: ModuleVulnerabilityCount[]): void { + if (!this.diagnostics) { + return; + } + + const diagnostics: Diagnostic[] = []; + for (const module of modules) { + if (!module.hasCount || !module.range) { + continue; + } + + const diagnosticMessage = this.getDiagnosticMessage(module); + if (!diagnosticMessage.length) { + continue; + } + + const range = this.languages.createRange( + module.range.start.line - 1, + module.range.start.column, + module.range.end.line - 1, + module.range.end.column, + ); + + const diagnostic = this.languages.createDiagnostic(range, diagnosticMessage, 1); // Warning severity + diagnostics.push({ + ...diagnostic, + source: DIAGNOSTICS_OSS_COLLECTION_NAME, + code: module.mostSevereVulnerabilityId, + }); + } + + this.diagnostics.set(document.uri, diagnostics); + } + + private shouldProcessFile(fileName: string, language: Language): boolean { + if ([Language.TypeScript, Language.JavaScript, Language.PJSON].includes(language)) { + const ossResult = this.ossService.getResultArray(); + if (!ossResult) { + return false; + } + + for (const fileResult of ossResult) { + if (this.vulnerabilityCountProvider.isFilePartOfOssTest(fileName, fileResult)) { + return true; + } + } + + return false; + } + + return true; + } + + private async getImportedModules( + fileName: string, + content: string, + language: Language, + emitter: VulnerabilityCountEmitter, + ): Promise { + try { + const modules = this.getModules(fileName, content, language).filter(isValidModuleName); + emitter.startScanning(modules); + + const promises = modules + .map(module => this.vulnerabilityCountProvider.getVulnerabilityCount(fileName, module, language)) + .map(promise => + promise.then(module => { + emitter.scanned(module); + return module; + }), + ); + const testedModules = await Promise.all(promises); + emitter.done(testedModules); + } catch (e) { + emitter.error(e); + } + } + + private getModules(fileName: string, source: string, language: Language): ImportedModule[] { + const parser = ModuleParserProvider.getInstance(language, this.logger, this.configuration); + if (!parser) { + return []; + } + + return parser.getModules(fileName, source, language); + } + + private getDiagnosticMessage(module: ModuleVulnerabilityCount): string { + if (!module.count) { + return ''; + } + + let message = messages.diagnosticMessagePrefix(module); + message += this.getSeverityCountMessage( + [ + ModuleVulnerabilityCountSeverity.Critical, + ModuleVulnerabilityCountSeverity.High, + ModuleVulnerabilityCountSeverity.Medium, + ModuleVulnerabilityCountSeverity.Low, + ], + module, + ); + message += messages.decoratorMessage(module.count); + return message; + } + + private getSeverityCountMessage( + severities: ModuleVulnerabilityCountSeverity[], + module: ModuleVulnerabilityCount, + ): string { + if (!module.severityCounts) { + return module.count ? module.count : ''; + } + + const content: string[] = []; + for (const severity of severities) { + if (module.severityCounts[severity] > 0) { + content.push(`${module.severityCounts[severity]} ${severity}`); + } + } + + return content.join(', '); + } +} diff --git a/src/snyk/snykOss/services/vulnerabilityCount/parsers/babelParser.ts b/src/snyk/snykOss/services/vulnerabilityCount/parsers/babelParser.ts new file mode 100644 index 000000000..e898fbb78 --- /dev/null +++ b/src/snyk/snykOss/services/vulnerabilityCount/parsers/babelParser.ts @@ -0,0 +1,158 @@ +import { parse as jsParse, ParserPlugin } from '@babel/parser'; +import traverse, { TraverseOptions } from '@babel/traverse'; +import * as t from '@babel/types'; +import { Identifier, ImportDeclaration, V8IntrinsicIdentifier } from '@babel/types'; +import { glob } from 'glob'; +import path from 'path'; +import { JAVASCRIPT_FILE_REGEX, TYPESCRIPT_FILE_REGEX } from '../../../../common/constants/languageConsts'; +import { ImportedModule, Language } from '../../../../common/types'; +import { ModuleParser } from './moduleParser'; + +const PARSE_PLUGINS: ParserPlugin[] = [ + 'jsx', + 'doExpressions', + 'objectRestSpread', + 'classProperties', + 'asyncGenerators', + 'functionBind', + 'functionSent', + 'dynamicImport', +]; +const PARSE_JS_PLUGINS: ParserPlugin[] = ['flow', ...PARSE_PLUGINS]; +const PARSE_TS_PLUGINS: ParserPlugin[] = ['typescript', ...PARSE_PLUGINS]; + +export class BabelParser extends ModuleParser { + getModules(fileName: string, source: string, language: Language): ImportedModule[] { + const modules: ImportedModule[] = []; + const visitor = this.getVisitor(fileName, modules); + + const ast = this.parse(source, language); + traverse(ast, visitor); + + return modules; + } + + private getVisitor(fileName: string, modules: ImportedModule[]): TraverseOptions { + return { + ImportDeclaration({ node }): void { + const target = `${path.dirname(fileName)}${path.sep}${node.source.value}`; + + if (!BabelParser.isLocalFile(target) && node.source.loc && node.loc) { + modules.push({ + fileName, + loc: node.source.loc, + name: node.source.value, + line: node.loc.end.line, + string: BabelParser.compileImportString(node), + }); + } + }, + CallExpression({ node }) { + if ((node.callee as V8IntrinsicIdentifier).name === 'require') { + modules.push({ + fileName, + name: BabelParser.getModuleName(node), + line: node.loc ? node.loc.end.line : null, + loc: node.arguments[0].loc ? node.arguments[0].loc : null, + string: BabelParser.compileRequireString(node), + }); + } else if (node.callee.type === 'Import') { + modules.push({ + fileName, + loc: node.arguments[0].loc ? node.arguments[0].loc : null, + name: BabelParser.getModuleName(node), + line: node.loc ? node.loc.end.line : null, + string: BabelParser.compileImportExpressionString(node), + }); + } + }, + }; + } + + private parse(source: string, language: Language) { + const plugins = language === Language.TypeScript ? PARSE_TS_PLUGINS : PARSE_JS_PLUGINS; + return jsParse(source, { + sourceType: 'module', + plugins, + }); + } + + private static isLocalFile(path: string): boolean { + const foundFiles = [...glob.sync(`${path}/index.*`), ...glob.sync(`${path}.*`)]; + if (!foundFiles.length) { + return false; + } + let fileExists = false; + + for (let idx = 0; idx < foundFiles.length; idx++) { + const file = foundFiles[idx]; + if (TYPESCRIPT_FILE_REGEX.test(file) || JAVASCRIPT_FILE_REGEX.test(file)) { + fileExists = true; + break; + } + } + + return fileExists; + } + + private static compileImportString(node: ImportDeclaration): string { + let importSpecifiers: string | undefined; + let importString: string; + + if (node.specifiers && node.specifiers.length > 0) { + importString = node.specifiers + .sort((s1, s2) => { + // Import specifiers are in statement order, which for mixed imports must be either "defaultImport, * as namespaceImport" + // or "defaultImport, { namedImport [as alias]... } according to current ECMA-262. + // Given that two equivalent import statements can only differ in the order of the items in a NamedImports block, + // we only need to sort these items in relation to each other to normalise the statements for caching purposes. + // Where the node is anything other than ImportSpecifier (Babel terminology for NamedImports), preserve the original statement order. + if (t.isImportSpecifier(s1) && t.isImportSpecifier(s2)) { + return (s1.imported as Identifier).name < (s2.imported as Identifier).name ? -1 : 1; + } + return 0; + }) + .map((specifier, i) => { + if (t.isImportNamespaceSpecifier(specifier)) { + return `* as ${specifier.local.name}`; + } else if (t.isImportDefaultSpecifier(specifier)) { + return specifier.local.name; + } else if (t.isImportSpecifier(specifier)) { + if (!importSpecifiers) { + importSpecifiers = '{'; + } + importSpecifiers += (specifier.imported as Identifier).name; + if (node.specifiers[i + 1] && t.isImportSpecifier(node.specifiers[i + 1])) { + importSpecifiers += ', '; + return undefined; + } else { + const result = importSpecifiers + '}'; + importSpecifiers = undefined; + return result; + } + } else { + return undefined; + } + }) + .filter(x => x) + .join(', '); + } else { + importString = '* as tmp'; + } + return `import ${importString} from '${node.source.value}';\nconsole.log(${importString.replace('* as ', '')});`; + } + + private static compileRequireString(node: t.CallExpression) { + return `require('${this.getModuleName(node)}')`; + } + + private static compileImportExpressionString(node: t.CallExpression) { + return `import('${this.getModuleName(node)}').then(res => console.log(res));`; + } + + private static getModuleName(node: t.CallExpression): string { + return t.isTemplateLiteral(node.arguments[0]) + ? node.arguments[0].quasis[0].value.raw + : (node.arguments[0] as t.StringLiteral).value; + } +} diff --git a/src/snyk/snykOss/services/vulnerabilityCount/parsers/htmlParser.ts b/src/snyk/snykOss/services/vulnerabilityCount/parsers/htmlParser.ts new file mode 100644 index 000000000..dc6254306 --- /dev/null +++ b/src/snyk/snykOss/services/vulnerabilityCount/parsers/htmlParser.ts @@ -0,0 +1,116 @@ +import * as htmlparser2 from 'htmlparser2'; +import { ImportedModule } from '../../../../common/types'; +import { ModuleParser } from './moduleParser'; + +class SupportedSources { + static readonly PathBased = [ + 'https://maxcdn.bootstrapcdn.com/', + 'https://yastatic.net/', + 'https://stackpath.bootstrapcdn.com/', + ]; + static readonly AtBased = ['https://cdn.jsdelivr.net/', 'https://unpkg.com/']; + + static readonly JQuery = 'https://code.jquery.com/'; + static readonly AspNetCDN = 'https://ajax.aspnetcdn.com/ajax/'; +} + +export class HtmlParser extends ModuleParser { + getModules(fileName: string, source: string): ImportedModule[] { + const packages: ImportedModule[] = []; + const parser = new htmlparser2.Parser( + { + onopentag: (name, attribs) => { + if ( + name === 'script' && + attribs.src && + (attribs.type || 'javascript/text').toLowerCase() === 'javascript/text' + ) { + const pkg = this.getPackageFromUrl(attribs.src); + + if (pkg) { + const [name, version] = pkg.split('@'); + + const offsetLines = this.getOffsetLines(parser.startIndex, source); + const currentLine = offsetLines.length; + + const currentLineOffsetLength = offsetLines[offsetLines.length - 1].length; + const startCol = source.substring(parser.startIndex).indexOf(attribs.src) + currentLineOffsetLength; + + packages.push({ + loc: { + start: { + line: currentLine, + column: startCol, + }, + end: { + line: currentLine, + column: startCol + attribs.src.length, + }, + }, + fileName, + line: currentLine, + name, + version, + string: attribs.src, + }); + } + } + }, + }, + { decodeEntities: true }, + ); + + parser.write(source); + parser.end(); + + return packages; + } + + private getPackageFromUrl(url: string): string | undefined { + let i = url.toLowerCase().indexOf('/ajax/libs/'); + url = url.replace(/(.slim)?(\.min)?.js$/, ''); + + if (i !== -1) { + i += '/ajax/libs/'.length; + const pkg = url.substring(i); + const [name, version = 'latest'] = pkg.split('/'); + return `${name}@${version}`; + } + + const isPathBased = SupportedSources.PathBased.find(_ => url.toLowerCase().startsWith(_)); + if (isPathBased) { + const pkg = url.substring(isPathBased.length); + const seperator = pkg.includes('/') ? '/' : '-'; + const [name, version = 'latest'] = pkg.split(seperator); + return `${name}@${version}`; + } + + if (url.toLowerCase().startsWith(SupportedSources.JQuery)) { + const pkg = url.substring(SupportedSources.JQuery.length); + const [name, ...version] = pkg.split('-'); + return `${name}@${version.join('-')}`; + } + + if (url.toLowerCase().startsWith(SupportedSources.AspNetCDN)) { + const pkg = url.substring(SupportedSources.AspNetCDN.length); + const [name, ...version] = pkg.split('-'); + return `${name.split('/').pop()}@${version.join('-')}`; + } + + const isAtBased = SupportedSources.AtBased.find(_ => url.toLowerCase().startsWith(_)); + if (isAtBased) { + const pkg = url + .substring(isAtBased.length) + .split('/') + .find(str => str.includes('@')); + + return pkg; + } + + return undefined; + } + + private getOffsetLines(index: number, source: string) { + return source.substring(0, index).split('\n'); + } +} diff --git a/src/snyk/snykOss/services/vulnerabilityCount/parsers/moduleParser.ts b/src/snyk/snykOss/services/vulnerabilityCount/parsers/moduleParser.ts new file mode 100644 index 000000000..55fcb46f9 --- /dev/null +++ b/src/snyk/snykOss/services/vulnerabilityCount/parsers/moduleParser.ts @@ -0,0 +1,5 @@ +import { ImportedModule, Language } from '../../../../common/types'; + +export abstract class ModuleParser { + abstract getModules(fileName: string, source: string, language: Language): ImportedModule[]; +} diff --git a/src/snyk/snykOss/services/vulnerabilityCount/parsers/packageJsonParser.ts b/src/snyk/snykOss/services/vulnerabilityCount/parsers/packageJsonParser.ts new file mode 100644 index 000000000..62be33178 --- /dev/null +++ b/src/snyk/snykOss/services/vulnerabilityCount/parsers/packageJsonParser.ts @@ -0,0 +1,132 @@ +import { ILog } from '../../../../common/logger/interfaces'; +import { ImportedModule, OssRange } from '../../../../common/types'; +import { ModuleParser } from './moduleParser'; + +export type PackageJsonDependencies = { + [dependency: string]: string; +}; + +export interface PackageJson { + dependencies: PackageJsonDependencies; + devDependencies: PackageJsonDependencies; +} + +export interface DependencyLines { + lines: string[]; + offset: number; +} + +export class PackageJsonParser extends ModuleParser { + constructor(private readonly logger: ILog, private readonly cliParameters?: string) { + super(); + } + + getModules(fileName: string, source: string): ImportedModule[] { + const lines: string[] = []; + source.split(/\r?\n/).forEach(function (line) { + lines.push(line); + }); + + let pjson = null; + + try { + pjson = JSON.parse(source) as PackageJson; + } catch (err: unknown) { + if (err instanceof Error) { + this.logger.error(`Failed to parse package.json file: ${err.message}`); + } + } + + if (!pjson) { + return []; + } + + const depLines = this.findDependencyLines(pjson, 'dependencies', lines); + const parseDevDeps = pjson.devDependencies && this.cliParameters?.includes('--dev'); + const devDepLines = parseDevDeps ? this.findDependencyLines(pjson, 'devDependencies', lines) : undefined; + + if (!depLines && !devDepLines) { + return []; + } + + const packages = []; + + if (depLines) { + for (const dependency in pjson.dependencies) { + packages.push(this.parseDependency(dependency, depLines, fileName)); + } + } + + if (devDepLines) { + for (const devDependency in pjson.devDependencies) { + packages.push(this.parseDependency(devDependency, devDepLines, fileName)); + } + } + + return packages; + } + + private parseDependency(dependency: string, depLines: DependencyLines, fileName: string) { + const loc = this.findRange(dependency, depLines.lines, depLines.offset); + + return { + fileName, + name: dependency, + loc, + line: loc?.start.line, + } as ImportedModule; + } + + private findDependencyLines( + pjson: PackageJson, + key: keyof PackageJson, + lines: string[], + ): DependencyLines | undefined { + const depStartLine = lines.find(x => x.includes(`"${key}"`)); + if (!depStartLine) { + return; + } + + const dependencies = pjson[key]; + const depLineIndex = lines.indexOf(depStartLine) + 1; + let dependenciesLength = Object.keys(dependencies).length; + const emptyLineTabRegex = /(\t)$/; + for (let i = depLineIndex + 1; i < lines.length - 1; i++) { + if (emptyLineTabRegex.test(lines[i]) || !lines[i] || !lines[i].trim()) { + dependenciesLength += 1; + continue; + } + + // end of dependencies. + if (lines[i].includes('}')) { + break; + } + } + const depLines = lines.slice(depLineIndex, depLineIndex + dependenciesLength); + + return { + lines: depLines, + offset: depLineIndex, + }; + } + + private findRange(dependency: string, lines: string[], offset: number): OssRange | undefined { + const line = lines.find(x => x.includes('"' + dependency + '"')); + if (!line) { + return; + } + + const index = offset + lines.indexOf(line) + 1; + + return { + start: { + line: index, + column: line.indexOf('"'), + }, + end: { + line: index, + column: line.length - 1, + }, + }; + } +} diff --git a/src/snyk/snykOss/services/vulnerabilityCount/vulnerabilityCountProvider.ts b/src/snyk/snykOss/services/vulnerabilityCount/vulnerabilityCountProvider.ts new file mode 100644 index 000000000..3c852ae33 --- /dev/null +++ b/src/snyk/snykOss/services/vulnerabilityCount/vulnerabilityCountProvider.ts @@ -0,0 +1,161 @@ +import { Language } from '../../../common/types'; +import { ILanguageClientAdapter } from '../../../common/vscode/languageClient'; +import { ITextDocumentAdapter } from '../../../common/vscode/textdocument'; +import { InlineValueText, LSPTextDocument } from '../../../common/vscode/types'; +import { IUriAdapter } from '../../../common/vscode/uri'; +import { OssFileResult, OssResultBody, OssVulnerability, isResultCliError } from '../../ossResult'; +import { OssService } from '../ossService'; +import { ImportedModule, ModuleVulnerabilityCount, SeverityCounts } from './importedModule'; + +export class ModuleVulnerabilityCountProvider { + constructor( + private readonly ossService: OssService, + private readonly lca: ILanguageClientAdapter, + private readonly uriAdapter: IUriAdapter, + private readonly textDocumentAdapter: ITextDocumentAdapter, + ) {} + + async getVulnerabilityCount( + fileName: string, + module: ImportedModule, + language: Language, + ): Promise { + const notCalculated = { + name: module.name, + fileName: module.fileName, + line: null, + range: null, + hasCount: false, + }; + + if ([Language.TypeScript, Language.JavaScript, Language.PJSON].includes(language)) { + // TODO use LS when OSS is moved to LS + const ossResult = this.ossService.getResultArray(); + if (!ossResult) { + return notCalculated; + } + + return this.mapOssResult(module, ossResult); + } else if (language == Language.HTML && module.loc) { + const uri = this.uriAdapter.file(fileName).toString(); + const doc: LSPTextDocument = this.textDocumentAdapter.create(uri, 'HTML', 1, ''); + const line = module.loc.start.line - 1; + const param = { + textDocument: { uri: doc.uri }, + range: { + start: { line: line, character: module.loc.start.column }, + end: { line: line, character: module.loc.end.column }, + }, + }; + const inlineValues: InlineValueText[] = await this.lca + .getLanguageClient() + .sendRequest('textDocument/inlineValue', param); + + if (inlineValues.length > 0) { + return { + name: module.name, + version: module.version, + fileName: module.fileName, + line: module.line, + range: module.loc, + count: inlineValues[0].text, + hasCount: true, + } as ModuleVulnerabilityCount; + } + } + + return notCalculated; + } + + isFilePartOfOssTest(filePath: string, ossFileResult: OssFileResult): boolean { + if (isResultCliError(ossFileResult)) { + return false; + } + + // File is considered to be part of OSS test if it has common root directory between OSS result path and filename path. + // This is since package.json always lies in the root directory folder of a project. + return filePath.startsWith(ossFileResult.path); + } + + private mapOssResult(module: ImportedModule, ossResult: ReadonlyArray): ModuleVulnerabilityCount { + const notCalculated = { + name: module.name, + fileName: module.fileName, + line: null, + hasCount: false, + range: null, + }; + + for (const fileResult of ossResult) { + if (!this.isFilePartOfOssTest(module.fileName, fileResult)) { + continue; + } + + const vulnerabilities = this.ossService.getUniqueVulnerabilities((fileResult as OssResultBody).vulnerabilities); + + // Sum up all vulnerabilities detected in first-level dependencies by OSS matching the imported module name. + // Ideally we want to use the same mechanism as NPM for determining the version used within users code. For now we stick with direct-vulnerability surfacing only. + const directVulnerabilities = vulnerabilities + .filter(v => v.name === module.name) + .filter(v => v.from.length == 2 && v.from[1].startsWith(module.name)); + const vulnerabilityCount = directVulnerabilities.length; + + // NPM allows declaration of the same direct dependency with multiple versions of it (e.g. {"dependencies": "webpack": "^4.44.1", "webpack": "^4.44.2",}). Thus, we should account for vulnerabilities that can be in different versions of the same package. + const hasSingleVersionVulnerability = directVulnerabilities.every( + vuln => vuln.version == directVulnerabilities[0].version, + ); + + let moduleVersion; + if (directVulnerabilities.length && hasSingleVersionVulnerability) { + moduleVersion = directVulnerabilities[0].version; + } + + const severityCounts = this.getSeverityCounts(directVulnerabilities); + const mostSevereVulnerability = this.getMostSevereVulnerability(directVulnerabilities); + + return { + name: module.name, + version: moduleVersion, + fileName: module.fileName, + count: `${vulnerabilityCount}`, + line: module.line, + range: module.loc, + hasCount: vulnerabilityCount > 0, + severityCounts, + mostSevereVulnerabilityId: mostSevereVulnerability?.id, + }; + } + + return notCalculated; + } + + private getSeverityCounts(directVulnerabilities: OssVulnerability[]): SeverityCounts { + return directVulnerabilities + .map(v => v.severity) + .reduce( + (arr, severity) => ({ + ...arr, + [severity]: directVulnerabilities.filter(v => v.severity == severity).length, + }), + {} as SeverityCounts, + ); + } + + private getMostSevereVulnerability(vulnerabilities: OssVulnerability[]): OssVulnerability | null { + return vulnerabilities.sort((a, b) => { + if (!a.cvssScore && !b.cvssScore) return 0; + if (!a.cvssScore) return 1; + if (!b.cvssScore) return -1; + + const cvssScore1 = parseFloat(a.cvssScore); + const cvssScore2 = parseFloat(b.cvssScore); + if (cvssScore1 > cvssScore2) { + return -1; + } else if (cvssScore1 < cvssScore2) { + return 1; + } + + return 0; + })?.[0]; + } +} diff --git a/src/snyk/snykOss/views/ossVulnerabilityTreeProvider.ts b/src/snyk/snykOss/views/ossVulnerabilityTreeProvider.ts new file mode 100644 index 000000000..17b9037b2 --- /dev/null +++ b/src/snyk/snykOss/views/ossVulnerabilityTreeProvider.ts @@ -0,0 +1,230 @@ +import { OpenCommandIssueType, OpenIssueCommandArg } from '../../common/commands/types'; +import { IConfiguration } from '../../common/configuration/configuration'; +import { SNYK_OPEN_ISSUE_COMMAND } from '../../common/constants/commands'; +import { SNYK_ANALYSIS_STATUS } from '../../common/constants/views'; +import { messages as commonMessages } from '../../common/messages/analysisMessages'; +import { IContextService } from '../../common/services/contextService'; +import { IViewManagerService } from '../../common/services/viewManagerService'; +import { AnalysisTreeNodeProvider } from '../../common/views/analysisTreeNodeProvider'; +import { INodeIcon, NODE_ICONS, TreeNode } from '../../common/views/treeNode'; +import { messages } from '../messages/treeView'; +import { isResultCliError, OssFileResult, OssSeverity, OssVulnerability } from '../ossResult'; +import { OssService } from '../services/ossService'; + +type ISeverityCounts = { + [key in OssSeverity]: number; +}; + +export type OssIssueCommandArg = OssVulnerability & { + matchingIdVulnerabilities: OssVulnerability[]; + overviewHtml: string; +}; + +export class OssVulnerabilityTreeProvider extends AnalysisTreeNodeProvider { + constructor( + protected readonly viewManagerService: IViewManagerService, + protected readonly contextService: IContextService, + protected readonly ossService: OssService, + protected readonly configuration: IConfiguration, + ) { + super(configuration, ossService); + } + + async getRootChildren(): Promise { + if (!this.configuration.getFeaturesConfiguration()?.ossEnabled) { + return [ + new TreeNode({ + text: SNYK_ANALYSIS_STATUS.OSS_DISABLED, + }), + ]; + } + + if (!this.contextService.shouldShowOssAnalysis) return []; + + if (!this.ossService.isLsDownloadSuccessful) { + return [this.getErrorEncounteredTreeNode()]; + } + + if (!this.ossService.isCliReady) { + return [ + new TreeNode({ + text: messages.cookingDependencies, + }), + ]; + } else if (!this.ossService.isAnyWorkspaceFolderTrusted) { + return [this.getNoWorkspaceTrustTreeNode()]; + } + + if (this.ossService.isAnalysisRunning) { + return [ + new TreeNode({ + text: commonMessages.scanRunning, + }), + ]; + } + + const ossResults = this.ossService.getResultArray(); + if (!ossResults) { + return [ + new TreeNode({ + text: messages.runTest, + }), + ]; + } + + const nodes: TreeNode[] = []; + const [resultNodes, totalVulnCount] = await this.getResultNodes(ossResults); + nodes.push(...resultNodes); + + if (ossResults.length == 1 && isResultCliError(ossResults[0])) { + return nodes; + } + + nodes.sort(this.compareNodes); + + const topNodes = [ + new TreeNode({ + text: this.getIssueFoundText(totalVulnCount), + }), + this.getDurationTreeNode(), + this.getNoSeverityFiltersSelectedTreeNode(), + ]; + nodes.unshift(...topNodes.filter((n): n is TreeNode => n !== null)); + + return nodes; + } + + protected getIssueFoundText(nIssues: number): string { + switch (nIssues) { + case 0: + return messages.noVulnerabilitiesFound; + case 1: + return messages.singleVulnerabilityFound; + default: + return messages.multipleVulnerabilitiesFound(nIssues); + } + } + + protected getIssueDescriptionText( + dir: string | undefined, + vulnerabilities: readonly OssVulnerability[], + ): string | undefined { + return `${dir} - ${vulnerabilities.length} ${ + vulnerabilities.length === 1 ? messages.vulnerability : messages.vulnerabilities + }`; + } + + static getSeverityIcon(severity: OssSeverity | string): INodeIcon { + return ( + { + [OssSeverity.Critical]: NODE_ICONS.critical, + [OssSeverity.High]: NODE_ICONS.high, + [OssSeverity.Medium]: NODE_ICONS.medium, + [OssSeverity.Low]: NODE_ICONS.low, + }[severity] || NODE_ICONS.low + ); + } + + static getFileSeverity(counts: ISeverityCounts): OssSeverity { + for (const s of [OssSeverity.Critical, OssSeverity.High, OssSeverity.Medium, OssSeverity.Low]) { + if (counts[s]) return s; + } + + return OssSeverity.Low; + } + + /** Returns severity significance index. The higher, the more significant severity is. */ + static getSeverityComparatorIndex(severity: OssSeverity): number { + return Object.values(OssSeverity).indexOf(severity); + } + + onDidChangeTreeData = this.viewManagerService.refreshOssViewEmitter.event; + + private initFileSeverityCounts(): ISeverityCounts { + return { + [OssSeverity.Critical]: 0, + [OssSeverity.High]: 0, + [OssSeverity.Medium]: 0, + [OssSeverity.Low]: 0, + }; + } + + protected getFilteredIssues(uniqueVulnerabilities: OssVulnerability[]): OssVulnerability[] { + return uniqueVulnerabilities.filter(vuln => { + switch (vuln.severity.toLowerCase()) { + case OssSeverity.Critical: + return this.configuration.severityFilter.critical; + case OssSeverity.High: + return this.configuration.severityFilter.high; + case OssSeverity.Medium: + return this.configuration.severityFilter.medium; + case OssSeverity.Low: + return this.configuration.severityFilter.low; + default: + return true; + } + }); + } + + private async getResultNodes(ossResults: ReadonlyArray): Promise<[TreeNode[], number]> { + const nodes: TreeNode[] = []; + let totalVulnCount = 0; + + for (const fileResult of ossResults) { + if (isResultCliError(fileResult)) { + nodes.push(this.getErrorEncounteredTreeNode(fileResult.path)); + continue; + } + + const counts: ISeverityCounts = this.initFileSeverityCounts(); + const vulnerabilityNodes: TreeNode[] = []; + + const uniqueVulns = this.ossService.getUniqueVulnerabilities(fileResult.vulnerabilities); + totalVulnCount += uniqueVulns.length; + + const fileVulnerabilities = this.getFilteredIssues(uniqueVulns); + if (fileVulnerabilities.length == 0) continue; + + for (const vuln of fileVulnerabilities) { + counts[vuln.severity]++; + vulnerabilityNodes.push( + new TreeNode({ + text: `${vuln.packageName}@${vuln.version} - ${vuln.title}`, + icon: OssVulnerabilityTreeProvider.getSeverityIcon(vuln.severity), + internal: { + severity: OssVulnerabilityTreeProvider.getSeverityComparatorIndex(vuln.severity), + }, + command: { + command: SNYK_OPEN_ISSUE_COMMAND, + title: '', + arguments: [ + { + issueType: OpenCommandIssueType.OssVulnerability, + // eslint-disable-next-line no-await-in-loop + issue: await this.ossService.getOssIssueCommandArg(vuln, fileResult.vulnerabilities), + } as OpenIssueCommandArg, + ], + }, + }), + ); + } + + vulnerabilityNodes.sort(this.compareNodes); + const fileSeverity = OssVulnerabilityTreeProvider.getFileSeverity(counts); + + const fileNode = new TreeNode({ + text: fileResult.displayTargetFile, + description: this.getIssueDescriptionText(fileResult.projectName, fileVulnerabilities), + icon: OssVulnerabilityTreeProvider.getSeverityIcon(fileSeverity), + children: vulnerabilityNodes, + internal: { + nIssues: vulnerabilityNodes.length, + severity: OssVulnerabilityTreeProvider.getSeverityComparatorIndex(fileSeverity), + }, + }); + nodes.push(fileNode); + } + + return [nodes, totalVulnCount]; + } +} diff --git a/src/snyk/snykOss/views/suggestion/ossSuggestionWebviewProvider.ts b/src/snyk/snykOss/views/suggestion/ossSuggestionWebviewProvider.ts new file mode 100644 index 000000000..8d9ad8b86 --- /dev/null +++ b/src/snyk/snykOss/views/suggestion/ossSuggestionWebviewProvider.ts @@ -0,0 +1,210 @@ +import * as vscode from 'vscode'; +import { SNYK_OPEN_BROWSER_COMMAND } from '../../../common/constants/commands'; +import { SNYK_VIEW_SUGGESTION_OSS } from '../../../common/constants/views'; +import { ErrorHandler } from '../../../common/error/errorHandler'; +import { ILog } from '../../../common/logger/interfaces'; +import { messages as learnMessages } from '../../../common/messages/learn'; +import { LearnService } from '../../../common/services/learnService'; +import { getNonce } from '../../../common/views/nonce'; +import { WebviewPanelSerializer } from '../../../common/views/webviewPanelSerializer'; +import { WebviewProvider } from '../../../common/views/webviewProvider'; +import { ExtensionContext } from '../../../common/vscode/extensionContext'; +import { IVSCodeWindow } from '../../../common/vscode/window'; +import { messages as errorMessages } from '../../messages/error'; +import { OssIssueCommandArg } from '../ossVulnerabilityTreeProvider'; + +enum OssSuggestionsViewEventMessageType { + OpenBrowser = 'openBrowser', +} + +type OssSuggestionViewEventMessage = { + type: OssSuggestionsViewEventMessageType; + value: unknown; +}; + +export class OssSuggestionWebviewProvider extends WebviewProvider { + constructor( + protected readonly context: ExtensionContext, + private readonly window: IVSCodeWindow, + protected readonly logger: ILog, + private readonly learnService: LearnService, + ) { + super(context, logger); + } + + activate(): void { + this.context.addDisposables( + this.window.registerWebviewPanelSerializer(SNYK_VIEW_SUGGESTION_OSS, new WebviewPanelSerializer(this)), + ); + } + + async postLearnLessonMessage(vulnerability: OssIssueCommandArg): Promise { + try { + if (this.panel) { + const lesson = await this.learnService.getOssLesson(vulnerability); + if (lesson) { + void this.panel.webview.postMessage({ + type: 'setLesson', + args: { url: lesson.url, title: learnMessages.lessonButtonTitle }, + }); + } else { + void this.panel.webview.postMessage({ + type: 'setLesson', + args: null, + }); + } + } + } catch (e) { + ErrorHandler.handle(e, this.logger, learnMessages.getLessonError); + } + } + + async showPanel(vulnerability: OssIssueCommandArg): Promise { + try { + await this.focusSecondEditorGroup(); + + if (this.panel) { + this.panel.reveal(vscode.ViewColumn.Two, true); + } else { + this.panel = vscode.window.createWebviewPanel( + SNYK_VIEW_SUGGESTION_OSS, + 'Snyk OSS Vulnerability', + { + viewColumn: vscode.ViewColumn.Two, + preserveFocus: true, + }, + this.getWebviewOptions(), + ); + this.registerListeners(); + } + + this.panel.webview.html = this.getHtmlForWebview(this.panel.webview); + this.panel.iconPath = vscode.Uri.joinPath( + vscode.Uri.file(this.context.extensionPath), + 'media', + 'images', + 'snyk-oss.svg', + ); + + void this.panel.webview.postMessage({ type: 'set', args: vulnerability }); + void this.postLearnLessonMessage(vulnerability); + } catch (e) { + ErrorHandler.handle(e, this.logger, errorMessages.suggestionViewShowFailed); + } + } + + protected registerListeners(): void { + if (!this.panel) return; + + this.panel.onDidDispose(() => this.onPanelDispose(), null, this.disposables); + this.panel.webview.onDidReceiveMessage( + (data: OssSuggestionViewEventMessage) => { + switch (data.type) { + case OssSuggestionsViewEventMessageType.OpenBrowser: + void vscode.commands.executeCommand(SNYK_OPEN_BROWSER_COMMAND, data.value); + break; + default: + break; + } + }, + null, + this.disposables, + ); + this.panel.onDidChangeViewState(() => this.checkVisibility(), null, this.disposables); + } + + protected getHtmlForWebview(webview: vscode.Webview): string { + const images: Record = [ + ['icon-code', 'svg'], + ['dark-critical-severity', 'svg'], + ['dark-high-severity', 'svg'], + ['dark-medium-severity', 'svg'], + ['dark-low-severity', 'svg'], + ['learn-icon', 'svg'], + ].reduce((accumulator: Record, [name, ext]) => { + const uri = this.getWebViewUri('media', 'images', `${name}.${ext}`); + if (!uri) throw new Error('Image missing.'); + accumulator[name] = uri.toString(); + return accumulator; + }, {}); + + const scriptUri = this.getWebViewUri( + 'out', + 'snyk', + 'snykOss', + 'views', + 'suggestion', + 'ossSuggestionWebviewScript.js', + ); + const styleUri = this.getWebViewUri('media', 'views', 'oss', 'suggestion', 'suggestion.css'); + const learnStyleUri = this.getWebViewUri('media', 'views', 'common', 'learn.css'); + + const nonce = getNonce(); + + return ` + + + + + + + + + + + + +
+
+
+ + + + + + + + + +
+
+
+
+ + +
+
+
+
+
Vulnerable module
+
+
+
+
Introduced through
+
+
+
+
Fixed in
+
+
+
+
Exploit maturity
+
+
+
+
+

Detailed paths

+
+
+
+
+
+
+ + + `; + } +} diff --git a/src/snyk/snykOss/views/suggestion/ossSuggestionWebviewScript.ts b/src/snyk/snykOss/views/suggestion/ossSuggestionWebviewScript.ts new file mode 100644 index 000000000..5dce4e32d --- /dev/null +++ b/src/snyk/snykOss/views/suggestion/ossSuggestionWebviewScript.ts @@ -0,0 +1,293 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/// +// This script will be run within the webview itself +// It cannot access the main VS Code APIs directly. +(function () { + // TODO: Redefine types until bundling is introduced into extension + // https://stackoverflow.com/a/56938089/1713082 + type Vulnerability = { + id: string; + license?: string; + identifiers?: Identifiers; + title: string; + description: string; + language: string; + packageManager: string; + packageName: string; + severity: string; + name: string; + version: string; + exploit?: string; + + CVSSv3?: string; + cvssScore?: string; + + fixedIn?: Array; + from: Array; + upgradePath: Array; + isPatchable: boolean; + isUpgradable: boolean; + + matchingIdVulnerabilities: Vulnerability[]; + overviewHtml: string; + }; + + type Lesson = { + url: string; + title: string; + }; + + type Identifiers = { + CWE: string[]; + CVE: string[]; + }; + + const vscode = acquireVsCodeApi(); + let vulnerability = {} as Vulnerability; + + let lesson: Lesson | null; + + function navigateToUrl(url: string) { + vscode.postMessage({ + type: 'openBrowser', + value: url, + }); + } + + function fillLearnLink() { + const learnWrapper = document.querySelector('.learn')!; + learnWrapper.className = 'learn'; + + if (lesson) { + const learnLink = document.querySelector('.learn--link')!; + learnLink.innerText = lesson.title; + const lessonUrl = lesson.url; + learnLink.onclick = () => navigateToUrl(lessonUrl); + learnWrapper.className = 'learn show'; + } + } + + function showCurrentSuggestion() { + const severity = document.querySelector('.severity')!; + const title = document.querySelector('.suggestion .suggestion-text')!; + + // Set title + title.innerHTML = vulnerability.title; + + // Set severity icon + setSeverityIcon(); + + // Fill identifiers line + fillIdentifiers(); + + // Fill summary + fillSummary(); + + // Fill detailed paths + fillDetailedPaths(); + + // Fill overview + fillOverview(); + + function setSeverityIcon() { + if (vulnerability.severity) { + severity.querySelectorAll('img').forEach(n => { + if (n.id.slice(-1) === 'l') { + if (n.id.includes(vulnerability.severity)) n.className = 'icon light-only'; + else n.className = 'icon light-only hidden'; + } else { + if (n.id.includes(vulnerability.severity)) n.className = 'icon dark-only'; + else n.className = 'icon dark-only hidden'; + } + }); + } else { + severity.querySelectorAll('img').forEach(n => (n.className = 'icon hidden')); + } + } + + function fillIdentifiers() { + const identifiers = document.querySelector('.identifiers')!; + identifiers.innerHTML = ''; // reset node + const type = vulnerability.license ? 'License' : 'Vulnerability'; + const typeNode = document.createTextNode(type); + identifiers.appendChild(typeNode); + + vulnerability.identifiers?.CVE.forEach(cve => + appendIdentifierSpan(identifiers, cve, `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${cve}`), + ); + vulnerability.identifiers?.CWE.forEach(cwe => appendIdentifierSpan(identifiers, cwe, getCweLink(cwe))); + if (vulnerability.cvssScore) appendIdentifierSpan(identifiers, `CVSS ${vulnerability.cvssScore}`); + appendIdentifierSpan(identifiers, vulnerability.id.toUpperCase(), `https://snyk.io/vuln/${vulnerability.id}`); + } + + function fillSummary() { + const module = document.querySelector('.module > .content')!; + module.textContent = vulnerability.name; + + const maturity = document.querySelector('.maturity > .content')!; + if (!vulnerability.exploit) { + maturity.classList.add('hidden'); + } else { + maturity.textContent = vulnerability.exploit; + } + + const introducedThrough = document.querySelector('.introduced-through > .content')!; + introducedThrough.innerHTML = ''; // reset node + if (vulnerability.from.length == 0) { + introducedThrough.classList.add('hidden'); + } else { + let modules = vulnerability.matchingIdVulnerabilities + .filter(vuln => vuln.from.length > 1) + .map(vuln => vuln.from[1]); + modules = [...new Set(modules)]; // obtain distinct only + + modules.forEach((module, i, arr) => { + appendIntroducedThroughSpan(introducedThrough, module, vulnerability.packageManager); + if (i != arr.length - 1) introducedThrough.append(document.createTextNode(', ')); + }); + } + + const fixedIn = document.querySelector('.fixed-in > .content')!; + fixedIn.innerHTML = ''; // reset node + if (!vulnerability.fixedIn || vulnerability.fixedIn.length == 0) { + fixedIn.append('Not fixed'); + } else { + fixedIn.append(vulnerability.name + '@'); + vulnerability.fixedIn.forEach((version, i, arr) => { + let versionStr = version; + if (i != arr.length - 1) versionStr = versionStr + ', '; + fixedIn.append(versionStr); + }); + } + } + + function fillDetailedPaths() { + const paths = document.querySelector('.detailed-paths')!; + paths.innerHTML = ''; // reset node + + vulnerability.matchingIdVulnerabilities.forEach(vuln => { + const introducedThrough = vuln.from.join(' > '); + + const isOutdated = vuln.upgradePath && vuln.upgradePath[1] === vuln.from[1]; + + // The logic as in registry + // https://github.com/snyk/registry/blob/5fe141a3c5eeb6b2c5e62cfa2b5a8643df29403d/frontend/src/components/IssueCardVulnerablePath/IssueCardVulnerablePath.vue#L109 + let remediationAdvice: string; + const upgradeMessage = `Upgrade to ${vuln.upgradePath[1]}`; + + if (vuln.isUpgradable || vuln.isPatchable) { + if (isOutdated) { + remediationAdvice = vuln.isPatchable ? upgradeMessage : getOutdatedDependencyMessage(vuln); + } else { + remediationAdvice = upgradeMessage; + } + } else { + remediationAdvice = 'none'; + } + + const html = ` +
+
Introduced through
+
${introducedThrough}
+
+
+
Remediation
+
${remediationAdvice}
+
`; + + const path = document.createElement('div'); + path.className = 'detailed-path'; + path.innerHTML = html; + paths.append(path); + }); + } + + function fillOverview() { + const overview = document.getElementById('overview')!; + overview.innerHTML = vulnerability.overviewHtml; + } + } + + function getCweLink(cwe: string) { + const id = cwe.toUpperCase().replace('CWE-', ''); + return `https://cwe.mitre.org/data/definitions/${id}.html`; + } + + function appendIdentifierSpan(identifiers: Element, id: string, link?: string) { + const delimiter = document.createElement('span'); + // delimiter.innerText = ' | '; + delimiter.className = 'delimiter'; + identifiers.appendChild(delimiter); + + let cveNode: HTMLElement; + if (link) { + cveNode = document.createElement('a'); + cveNode.onclick = () => navigateToUrl(link); + } else { + cveNode = document.createElement('span'); + } + + cveNode.innerText = id; + + identifiers.appendChild(cveNode); + } + + function appendIntroducedThroughSpan(introducedThrough: Element, module: string, packageManager: string) { + const supportedPackageManagers = ['npm']; + + let node: HTMLElement; + // replicate app.snyk.io linking logic from https://github.com/snyk/registry/blob/c78f0ae84dc20f25146880b3d3d5661f3d3e4db2/frontend/src/lib/issue-utils.ts#L547 + if (supportedPackageManagers.includes(packageManager.toLowerCase())) { + node = document.createElement('a'); + node.onclick = () => navigateToUrl(`https://app.snyk.io/test/${packageManager}/${module}`); + } else { + node = document.createElement('span'); + } + + node.innerText = module; + introducedThrough.appendChild(node); + } + + function getOutdatedDependencyMessage(vulnerability: Vulnerability) { + return `Your dependencies are out of date, otherwise you would be using a newer ${vulnerability.name} than ${ + vulnerability.name + }@${vulnerability.version}. + ${ + ['npm', 'yarn', 'yarn-workspace'].includes(vulnerability.packageManager) + ? `Try relocking your lockfile or deleting node_modules and reinstalling your dependencies. If the problem persists, one of your dependencies may be bundling outdated modules.` + : 'Try reinstalling your dependencies. If the problem persists, one of your dependencies may be bundling outdated modules.' + }`; + } + + window.addEventListener('message', event => { + const { type, args } = event.data; + switch (type) { + case 'set': { + vulnerability = args; + vscode.setState({ ...vscode.getState(), vulnerability }); + showCurrentSuggestion(); + break; + } + case 'get': { + vulnerability = vscode.getState()?.vulnerability || {}; + showCurrentSuggestion(); + break; + } + case 'setLesson': { + lesson = args; + vscode.setState({ ...vscode.getState(), lesson }); + fillLearnLink(); + break; + } + case 'getLesson': { + lesson = vscode.getState()?.lesson || null; + fillLearnLink(); + break; + } + } + }); +})(); diff --git a/src/snyk/snykOss/vulnerabilityCountEmitter.ts b/src/snyk/snykOss/vulnerabilityCountEmitter.ts new file mode 100644 index 000000000..4564f0742 --- /dev/null +++ b/src/snyk/snykOss/vulnerabilityCountEmitter.ts @@ -0,0 +1,32 @@ +import EventEmitter from 'events'; +import { ImportedModule, ModuleVulnerabilityCount } from './services/vulnerabilityCount/importedModule'; + +export enum VulnerabilityCountEvents { + PackageJsonFound = 'packageJsonFound', + Start = 'start', + Scanned = 'scanned', + Done = 'done', + Error = 'error', +} + +export class VulnerabilityCountEmitter extends EventEmitter { + packageJsonFound(fileName: string): void { + this.emit(VulnerabilityCountEvents.PackageJsonFound, fileName); + } + + startScanning(importedModules: ImportedModule[]): void { + this.emit(VulnerabilityCountEvents.Start, importedModules); + } + + scanned(moduleInfo: ModuleVulnerabilityCount): void { + this.emit(VulnerabilityCountEvents.Scanned, moduleInfo); + } + + done(moduleInfos: ModuleVulnerabilityCount[]): void { + this.emit(VulnerabilityCountEvents.Done, moduleInfos); + } + + error(error: Error | unknown): void { + this.emit(VulnerabilityCountEvents.Error, error); + } +} diff --git a/src/snyk/snykOss/watchers/dailyScanJob.ts b/src/snyk/snykOss/watchers/dailyScanJob.ts new file mode 100644 index 000000000..6b21793b8 --- /dev/null +++ b/src/snyk/snykOss/watchers/dailyScanJob.ts @@ -0,0 +1,19 @@ +import { IExtension } from '../../base/modules/interfaces'; + +export class DailyScanJob { + private readonly dayInMs = 86400000; + private job: NodeJS.Timeout; + + constructor(private readonly extension: IExtension) {} + + schedule(): void { + if (this.job) { + this.job.refresh(); + return; + } + + this.job = setTimeout(() => { + void this.extension.runOssScan(false); + }, this.dayInMs); + } +} diff --git a/src/snyk/snykOss/watchers/manifestFileWatcher.ts b/src/snyk/snykOss/watchers/manifestFileWatcher.ts new file mode 100644 index 000000000..9a13c05c1 --- /dev/null +++ b/src/snyk/snykOss/watchers/manifestFileWatcher.ts @@ -0,0 +1,59 @@ +import * as vscode from 'vscode'; +import { IExtension } from '../../base/modules/interfaces'; +import { IConfiguration } from '../../common/configuration/configuration'; +import { IVSCodeWorkspace } from '../../common/vscode/workspace'; + +// to be kept in sync with Snyk CLI support list +// copied from https://github.com/snyk/snyk/blob/93ec5896282e3ba1389dc5604589d2773a4bf517/src/lib/package-managers.ts#L21 +enum SUPPORTED_MANIFEST_FILES { + GEMFILE = 'Gemfile', + GEMFILE_LOCK = 'Gemfile.lock', + GEMSPEC = '*.gemspec', + PACKAGE_LOCK_JSON = 'package-lock.json', + POM_XML = 'pom.xml', + JAR = '*.jar', + WAR = '*.war', + BUILD_GRADLE = 'build.gradle', + BUILD_GRADLE_KTS = 'build.gradle.kts', + BUILD_SBT = 'build.sbt', + YARN_LOCK = 'yarn.lock', + PACKAGE_JSON = 'package.json', + PIPFILE = 'Pipfile', + SETUP_PY = 'setup.py', + REQUIREMENTS_TXT = 'requirements.txt', + GOPKG_LOCK = 'Gopkg.lock', + GO_MOD = 'go.mod', + VENDOR_JSON = 'vendor.json', + PROJECT_ASSETS_JSON = 'project.assets.json', + PACKAGES_CONFIG = 'packages.config', + PROJECT_JSON = 'project.json', + PAKET_DEPENDENCIES = 'paket.dependencies', + COMPOSER_LOCK = 'composer.lock', + PODFILE_LOCK = 'Podfile.lock', + COCOAPODS_PODFILE_YAML = 'CocoaPods.podfile.yaml', + COCOAPODS_PODFILE = 'CocoaPods.podfile', + PODFILE = 'Podfile', + POETRY_LOCK = 'poetry.lock', + MIX_EXS = 'mix.exs', +} + +export default function createManifestFileWatcher( + extension: IExtension, + workspace: IVSCodeWorkspace, + configuration: IConfiguration, +): vscode.FileSystemWatcher { + const globPattern = `**/{${Object.values(SUPPORTED_MANIFEST_FILES).join(',')}}`; + const watcher = workspace.createFileSystemWatcher(globPattern); + + watcher.onDidChange(() => runOssScan()); + watcher.onDidDelete(() => runOssScan()); + watcher.onDidCreate(() => runOssScan()); + + function runOssScan() { + if (configuration.shouldAutoScanOss) { + void extension.runOssScan(); + } + } + + return watcher; +}