Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add recommendations and remediation from trusted content via quickfix code actions #231

Merged
merged 2 commits into from
Dec 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
76 changes: 74 additions & 2 deletions src/codeActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,57 @@
* ------------------------------------------------------------------------------------------ */
'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[]>();

/**
* Gets the code actions map.
*/
function getCodeActionsMap(): Map<string, CodeAction[]> {
return codeActionsMap;
}

/**
* 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);
let hasRhdaDiagonostic: boolean = false;
const codeActions: CodeAction[] = [];

for (const diagnostic of diagnostics) {

const key = `${diagnostic.range.start.line}|${diagnostic.range.start.character}`;
const diagnosticCodeActions = codeActionsMap[key] || [];
codeActions.push(...diagnosticCodeActions);

hasRhdaDiagonostic ||= diagnostic.source === RHDA_DIAGNOSTIC_SOURCE;
}

if (globalConfig.triggerFullStackAnalysis && hasRhdaDiagonostic) {
codeActions.push(generateFullStackAnalysisAction());
}
Expand All @@ -39,4 +77,38 @@ 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
}];

if (diagnostic.severity !== 1 && path.basename(uri) === 'pom.xml') {
codeAction.command = {
title: 'RedHat repository recommendation',
command: globalConfig.triggerRHRepositoryRecommendationNotification,
};
}

return codeAction;
}

export { getCodeActionsMap, 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', 'ref') ? issue.remediation.trustedContent.ref.split('?')[0] : '';
}

/**
* 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
Loading