diff --git a/package-lock.json b/package-lock.json index af8d39d6..b640f8a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.8.1-ea.2", "license": "Apache-2.0", "dependencies": { - "@RHEcosystemAppEng/exhort-javascript-api": "^0.1.1-ea.3", + "@RHEcosystemAppEng/exhort-javascript-api": "^0.1.1-ea.4", "@xml-tools/ast": "^5.0.5", "@xml-tools/parser": "^1.0.11", "json-to-ast": "^2.1.0", @@ -837,9 +837,9 @@ } }, "node_modules/@RHEcosystemAppEng/exhort-javascript-api": { - "version": "0.1.1-ea.3", - "resolved": "https://npm.pkg.github.com/download/@RHEcosystemAppEng/exhort-javascript-api/0.1.1-ea.3/64de57546f9958f378c8ad22ec18cedaec95b4ed", - "integrity": "sha512-ydfi0HL3uFlejGeyJbCalJQAz0nPO4NKjBEaj/awb5qN/HIONzB6KnjGQI2rvws4Iwhi+LvRptfRgH3aZtwejw==", + "version": "0.1.1-ea.4", + "resolved": "https://npm.pkg.github.com/download/@RHEcosystemAppEng/exhort-javascript-api/0.1.1-ea.4/cad7237062cf4467fcf76ee9151dbfb50037e233", + "integrity": "sha512-qKmcb2rfP26iaSwdi5LKAj2l2kiyBRfvf/gD1gYwXoQBSNfrz3rMCQJb2HZoRmEQfn0mE/mqMe0ZEfgdDQBHCw==", "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.23.2", diff --git a/package.json b/package.json index f1ff9a4e..44aeba98 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dist" ], "dependencies": { - "@RHEcosystemAppEng/exhort-javascript-api": "^0.1.1-ea.3", + "@RHEcosystemAppEng/exhort-javascript-api": "^0.1.1-ea.4", "@xml-tools/ast": "^5.0.5", "@xml-tools/parser": "^1.0.11", "json-to-ast": "^2.1.0", diff --git a/src/codeActionHandler.ts b/src/codeActionHandler.ts index 4c7c91b5..43e8859c 100644 --- a/src/codeActionHandler.ts +++ b/src/codeActionHandler.ts @@ -4,19 +4,61 @@ * ------------------------------------------------------------------------------------------ */ 'use strict'; +import * as path from 'path'; import { CodeAction, CodeActionKind, Diagnostic } from 'vscode-languageserver/node'; + import { globalConfig } from './config'; import { RHDA_DIAGNOSTIC_SOURCE } from './constants'; +let codeActionsMap: Map = new Map(); + +/** + * Clears the code actions map. + */ +function clearCodeActionsMap() { + codeActionsMap = new Map(); +} + +/** + * Registers a code action. + * @param key - The key to register the code action against. + * @param codeAction - The code action to be registered. + */ +function registerCodeAction(key: string, codeAction: CodeAction) { + codeActionsMap[key] = codeActionsMap[key] || []; + codeActionsMap[key].push(codeAction); +} + /** * Retrieves code actions based on diagnostics and file type. * @param diagnostics - An array of available diagnostics. + * @param uri - The URI of the file being analyzed. * @returns An array of CodeAction objects to be made available to the user. */ -function getDiagnosticsCodeActions(diagnostics: Diagnostic[]): CodeAction[] { - const hasRhdaDiagonostic = diagnostics.some(diagnostic => diagnostic.source === RHDA_DIAGNOSTIC_SOURCE); +function getDiagnosticsCodeActions(diagnostics: Diagnostic[], uri: string): CodeAction[] { + let hasRhdaDiagonostic: boolean = false; const codeActions: CodeAction[] = []; + for (const diagnostic of diagnostics) { + const diagnosticCodeActions = codeActionsMap[diagnostic.range.start.line + '|' + diagnostic.range.start.character]; + if (diagnosticCodeActions && diagnosticCodeActions.length !== 0) { + + if (path.basename(uri) === 'pom.xml') { + diagnosticCodeActions.forEach(codeAction => { + codeAction.command = { + title: 'RedHat repository recommendation', + command: globalConfig.triggerRHRepositoryRecommendationNotification, + }; + }); + } + + codeActions.push(...diagnosticCodeActions); + + } + + hasRhdaDiagonostic ||= diagnostic.source === RHDA_DIAGNOSTIC_SOURCE; + } + if (globalConfig.triggerFullStackAnalysis && hasRhdaDiagonostic) { codeActions.push(generateFullStackAnalysisAction()); } @@ -39,4 +81,30 @@ function generateFullStackAnalysisAction(): CodeAction { }; } -export { getDiagnosticsCodeActions }; \ No newline at end of file +/** + * Generates a code action to switch to the recommended version. + * @param title - The title of the code action. + * @param versionReplacementString - The version replacement string. + * @param diagnostic - The diagnostic information. + * @param uri - The URI of the file. + * @returns A CodeAction object for switching to the recommended version. + */ +function generateSwitchToRecommendedVersionAction(title: string, versionReplacementString: string, diagnostic: Diagnostic, uri: string): CodeAction { + const codeAction: CodeAction = { + title: title, + diagnostics: [diagnostic], + kind: CodeActionKind.QuickFix, + edit: { + changes: { + } + } + }; + + codeAction.edit.changes[uri] = [{ + range: diagnostic.range, + newText: versionReplacementString + }]; + return codeAction; +} + +export { clearCodeActionsMap, registerCodeAction , generateSwitchToRecommendedVersionAction, getDiagnosticsCodeActions }; \ No newline at end of file diff --git a/src/componentAnalysis.ts b/src/componentAnalysis.ts index 4af609a3..5f2465c0 100644 --- a/src/componentAnalysis.ts +++ b/src/componentAnalysis.ts @@ -35,6 +35,8 @@ class Source implements ISource { interface IDependencyData { sourceId: string; issuesCount: number; + recommendationRef: string; + remediationRef: string highestVulnerabilitySeverity: string; } @@ -45,6 +47,8 @@ class DependencyData implements IDependencyData { constructor( public sourceId: string, public issuesCount: number, + public recommendationRef: string, + public remediationRef: string, public highestVulnerabilitySeverity: string ) {} } @@ -69,10 +73,12 @@ class AnalysisResponse implements IAnalysisResponse { if (isDefined(resData, 'providers')) { Object.entries(resData.providers).map(([providerName, providerData]) => { - if (isDefined(providerData, 'status', 'ok') && isDefined(providerData, 'sources') && providerData.status.ok) { - Object.entries(providerData.sources).map(([sourceName, sourceData]) => { - sources.push(new Source(`${providerName}(${sourceName})`, isDefined(sourceData, 'dependencies') ? sourceData.dependencies : [])); - }); + if (isDefined(providerData, 'status', 'ok') && providerData.status.ok) { + if (isDefined(providerData, 'sources')) { + Object.entries(providerData.sources).map(([sourceName, sourceData]) => { + sources.push(new Source(`${providerName}(${sourceName})`, this.getDependencies(sourceData))); + }); + } } else { failedProviders.push(providerName); } @@ -89,17 +95,61 @@ class AnalysisResponse implements IAnalysisResponse { sources.forEach(source => { source.dependencies.forEach(d => { - if (isDefined(d, 'ref') && isDefined(d, 'issues')) { - const dd = new DependencyData(source.id, d.issues.length, isDefined(d, 'highestVulnerability', 'severity') ? d.highestVulnerability.severity : 'NONE'); - if (this.dependencies[d.ref] === undefined) { - this.dependencies[d.ref] = []; - } + if (isDefined(d, 'ref')) { + + const issuesCount: number = isDefined(d, 'issues') ? d.issues.length : 0; + + const dd = issuesCount + ? new DependencyData(source.id, issuesCount, '', this.getRemediation(d.issues[0]), this.getHighestSeverity(d)) + : new DependencyData(source.id, issuesCount, this.getRecommendation(d), '', this.getHighestSeverity(d)); + + this.dependencies[d.ref] = this.dependencies[d.ref] || []; this.dependencies[d.ref].push(dd); } }); }); } } + + /** + * Retrieves dependencies from source. + * @param sourceData The source object. + * @returns An array of dependencies or empty array if none exists. + * @private + */ + private getDependencies(sourceData: any): any[] { + return isDefined(sourceData, 'dependencies') ? sourceData.dependencies : []; + } + + /** + * Retrieves the highest vulnerability severity value from a dependency. + * @param dependency The dependency object. + * @returns The highest severity level or NONE if none exists. + * @private + */ + private getHighestSeverity(dependency: any): string { + return isDefined(dependency, 'highestVulnerability', 'severity') ? dependency.highestVulnerability.severity : 'NONE'; + } + + /** + * Retrieves the remediation reference from an issue. + * @param issue The issue object. + * @returns The remediation reference or empty string if none exists. + * @private + */ + private getRemediation(issue: any): string { + return isDefined(issue, 'remediation', 'trustedContent', 'package') ? issue.remediation.trustedContent.package : ''; + } + + /** + * Retrieves the recommendation reference from a dependency. + * @param dependency The dependency object. + * @returns The recommendation reference or empty string if none exists. + * @private + */ + private getRecommendation(dependency: any): string { + return isDefined(dependency, 'recommendation') ? dependency.recommendation.split('?')[0] : ''; + } } /** diff --git a/src/config.ts b/src/config.ts index f6493e7b..be72f282 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,26 +10,28 @@ */ class Config { - triggerFullStackAnalysis: string; - telemetryId: string; - utmSource: string; - exhortSnykToken: string; - exhortOSSIndexUser: string; - exhortOSSIndexToken: string; - matchManifestVersions: string; - exhortMvnPath: string; - exhortNpmPath: string; - exhortGoPath: string; - exhortPython3Path: string; - exhortPip3Path: string; - exhortPythonPath: string; - exhortPipPath: string; + triggerFullStackAnalysis: string; + triggerRHRepositoryRecommendationNotification: string; + telemetryId: string; + utmSource: string; + exhortSnykToken: string; + exhortOSSIndexUser: string; + exhortOSSIndexToken: string; + matchManifestVersions: string; + exhortMvnPath: string; + exhortNpmPath: string; + exhortGoPath: string; + exhortPython3Path: string; + exhortPip3Path: string; + exhortPythonPath: string; + exhortPipPath: string; /** * Initializes a new instance of the Config class with default values from the parent process environment variable data. */ constructor() { this.triggerFullStackAnalysis = process.env.VSCEXT_TRIGGER_FULL_STACK_ANALYSIS || ''; + this.triggerRHRepositoryRecommendationNotification = process.env.VSCEXT_TRIGGER_REDHAT_REPOSITORY_RECOMMENDATION_NOTIFICATION || ''; this.telemetryId = process.env.VSCEXT_TELEMETRY_ID || ''; this.utmSource = process.env.VSCEXT_UTM_SOURCE || ''; this.exhortSnykToken = process.env.VSCEXT_EXHORT_SNYK_TOKEN || ''; diff --git a/src/diagnosticsHandler.ts b/src/diagnosticsHandler.ts index 5df74c3a..72035a71 100644 --- a/src/diagnosticsHandler.ts +++ b/src/diagnosticsHandler.ts @@ -5,10 +5,14 @@ 'use strict'; import { Diagnostic } from 'vscode-languageserver'; + import { DependencyMap, IDependencyProvider, getRange } from './collector'; import { executeComponentAnalysis, DependencyData } from './componentAnalysis'; import { Vulnerability } from './vulnerability'; import { connection } from './server'; +import { VERSION_PLACEHOLDER } from './constants'; +import { clearCodeActionsMap, registerCodeAction, generateSwitchToRecommendedVersionAction } from './codeActionHandler'; +import { IPositionedContext } from './collector' /** * Diagnostics Pipeline specification. @@ -61,8 +65,10 @@ class DiagnosticsPipeline implements IDiagnosticsPipeline { runDiagnostics(dependencies: Map) { Object.entries(dependencies).map(([ref, dependencyData]) => { - const dependency = this.dependencyMap.get(this.provider.resolveDependencyFromReference(ref).split('@')[0]); - if (dependency !== undefined) { + const dependencyRef = this.provider.resolveDependencyFromReference(ref).split('@')[0]; + const dependency = this.dependencyMap.get(dependencyRef); + + if (dependency) { const vulnerability = new Vulnerability( this.provider, getRange(dependency), @@ -73,20 +79,41 @@ class DiagnosticsPipeline implements IDiagnosticsPipeline { const vulnerabilityDiagnostic = vulnerability.getDiagnostic(); this.diagnostics.push(vulnerabilityDiagnostic); - dependencyData.forEach((dd) => { - const vulnProvider = dd.sourceId.split('(')[0]; - const issuesCount = dd.issuesCount; + const loc = vulnerabilityDiagnostic.range.start.line + '|' + vulnerabilityDiagnostic.range.start.character; - if (vulnProvider in this.vulnCount) { - this.vulnCount[vulnProvider] += issuesCount; - } else { - this.vulnCount[vulnProvider] = issuesCount; + dependencyData.forEach(dd => { + + const actionRef = vulnerabilityDiagnostic.severity === 1 ? dd.remediationRef : dd.recommendationRef; + + if (actionRef) { + this.createCodeAction(loc, actionRef, dependency.context, dd.sourceId, vulnerabilityDiagnostic); } - }); + + const vulnProvider = dd.sourceId.split('(')[0]; + const issuesCount = dd.issuesCount; + this.vulnCount[vulnProvider] = (this.vulnCount[vulnProvider] || 0) + issuesCount; + }); } connection.sendDiagnostics({ uri: this.diagnosticFilePath, diagnostics: this.diagnostics }); }); } + + /** + * Creates a code action. + * @param loc - Location of code action effect. + * @param ref - The reference name of the recommended package. + * @param context - Dependency context object. + * @param sourceId - Source ID. + * @param vulnerabilityDiagnostic - Vulnerability diagnostic object. + * @private + */ + private createCodeAction(loc: string, ref: string, context: IPositionedContext, sourceId: string, vulnerabilityDiagnostic: Diagnostic) { + const switchToVersion = this.provider.resolveDependencyFromReference(ref).split('@')[1] + const versionReplacementString = context ? context.value.replace(VERSION_PLACEHOLDER, switchToVersion) : switchToVersion; + const title = `Switch to version ${switchToVersion} for ${sourceId}`; + const codeAction = generateSwitchToRecommendedVersionAction(title, versionReplacementString, vulnerabilityDiagnostic, this.diagnosticFilePath); + registerCodeAction(loc, codeAction); + } } /** @@ -113,6 +140,8 @@ async function performDiagnostics(diagnosticFilePath: string, contents: string, const diagnosticsPipeline = new DiagnosticsPipeline(provider, dependencyMap, diagnosticFilePath); + clearCodeActionsMap(); + diagnosticsPipeline.clearDiagnostics(); const analysis = executeComponentAnalysis(diagnosticFilePath, contents) diff --git a/src/server.ts b/src/server.ts index dd8fb142..e5d36ac7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -108,7 +108,7 @@ connection.onDidChangeConfiguration(() => { * Handles code action requests from client. */ connection.onCodeAction((params): CodeAction[] => { - return getDiagnosticsCodeActions(params.context.diagnostics); + return getDiagnosticsCodeActions(params.context.diagnostics, params.textDocument.uri); }); connection.listen(); diff --git a/src/vulnerability.ts b/src/vulnerability.ts index 77a84450..64127001 100644 --- a/src/vulnerability.ts +++ b/src/vulnerability.ts @@ -28,28 +28,54 @@ class Vulnerability { private dependencyData: DependencyData[], ) {} + /** + * Generate vulnerability information message + * @param dependencyData Properties of the dependency object + * @returns vulnerability information message string + */ + private generateVulnerabilityInfo(dependencyData: DependencyData): string { + return `${dependencyData.sourceId} vulnerability info: +Known security vulnerabilities: ${dependencyData.issuesCount} +Highest severity: ${dependencyData.highestVulnerabilitySeverity}`; + } + + /** + * Generate recommendation message from source + * @param dependencyData Properties of the dependency object + * @returns source recommendation message string + */ + private generateRecommendation(dependencyData: DependencyData): string { + return `${dependencyData.sourceId} vulnerability info: +Known security vulnerabilities: ${dependencyData.issuesCount} +Recommendation: ${this.provider.resolveDependencyFromReference(dependencyData.recommendationRef) || 'No RedHat packages to recommend'}`; + } + + /** * Creates a diagnostic object based on vulnerability data. * @returns A Diagnostic object representing the vulnerability. */ getDiagnostic(): Diagnostic { + + const hasIssues = this.dependencyData.some(data => data.issuesCount > 0); - const diagSeverity = DiagnosticSeverity.Error; - - let msg: string = `${this.provider.resolveDependencyFromReference(this.ref)}`; + const diagnosticSeverity = hasIssues ? DiagnosticSeverity.Error : DiagnosticSeverity.Information; - this.dependencyData.forEach(dd => { - msg += ` - -${dd.sourceId} vulnerability info: -Known security vulnerabilities: ${dd.issuesCount} -Highest severity: ${dd.highestVulnerabilitySeverity}`; + const messages = this.dependencyData.map(dd => { + if (hasIssues && dd.issuesCount > 0) { + return this.generateVulnerabilityInfo(dd); + } else if (!hasIssues) { + return this.generateRecommendation(dd); + } + return ''; }); + + const message = `${this.provider.resolveDependencyFromReference(this.ref)}\n\n${messages.join('\n\n')}`; return { - severity: diagSeverity, + severity: diagnosticSeverity, range: this.range, - message: msg, + message: message, source: RHDA_DIAGNOSTIC_SOURCE, }; }