Skip to content

Commit

Permalink
feat: added remediation and recommendation quickfix feature
Browse files Browse the repository at this point in the history
Signed-off-by: Ilona Shishov <[email protected]>
  • Loading branch information
IlonaShishov committed Dec 17, 2023
1 parent 50403dd commit 76b2a70
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 53 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 71 additions & 3 deletions src/codeActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CodeAction[]> = new Map<string, CodeAction[]>();

/**
* Clears the code actions map.
*/
function clearCodeActionsMap() {
codeActionsMap = new Map<string, CodeAction[]>();
}

/**
* 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());
}
Expand All @@ -39,4 +81,30 @@ function generateFullStackAnalysisAction(): CodeAction {
};
}

export { getDiagnosticsCodeActions };
/**
* 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 };
68 changes: 59 additions & 9 deletions src/componentAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class Source implements ISource {
interface IDependencyData {
sourceId: string;
issuesCount: number;
recommendationRef: string;
remediationRef: string
highestVulnerabilitySeverity: string;
}

Expand All @@ -45,6 +47,8 @@ class DependencyData implements IDependencyData {
constructor(
public sourceId: string,
public issuesCount: number,
public recommendationRef: string,
public remediationRef: string,
public highestVulnerabilitySeverity: string
) {}
}
Expand All @@ -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);
}
Expand All @@ -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] : '';
}
}

/**
Expand Down
30 changes: 16 additions & 14 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';
Expand Down
49 changes: 39 additions & 10 deletions src/diagnosticsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -61,8 +65,10 @@ class DiagnosticsPipeline implements IDiagnosticsPipeline {

runDiagnostics(dependencies: Map<string, DependencyData[]>) {
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),
Expand All @@ -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);
}
}

/**
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading

0 comments on commit 76b2a70

Please sign in to comment.