Skip to content

Commit

Permalink
OWL-884 feat: add learn jit to webpanel (#187)
Browse files Browse the repository at this point in the history
  • Loading branch information
denis-snyk authored Apr 19, 2022
1 parent 3f11305 commit 6daa113
Show file tree
Hide file tree
Showing 20 changed files with 498 additions and 22 deletions.
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
* @snyk/road-runner
src/snyk/common/services/learnService.ts @snyk/owl
src/test/unit/common/services/learnService.test.ts @snyk/owl
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Snyk Code: add support for Single Tenant setups
- Update organization setting description to clarify expected value.
- Snyk Open Source: vulnerability count is shown in NPM `devDependencies` when `--dev` flag is passed to Snyk CLI via additional arguments.
- Vulnerability detail views now have links to Snyk Learn when we have an appropriate lesson available.

## [1.2.13]

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ example variable names of your code and the line numbers in red. You can also se
- Code from open source repositories that might be of help to see how others fixed the issue.
- You can add ignore comments that would make Snyk ignore this particular suggestion, or all of these suggestions for
the whole file, by using the two buttons on the lower end of the panel.
- If available, provides a link to an interactive lesson explaining the vulnerability on Snyk Learn.

We also include a feedback mechanism to report false positives so that others do not see the same issue.

Expand Down Expand Up @@ -234,6 +235,7 @@ OSS vulnerability tab shows information about the vulnerable module.
- Displays CVSS score and [exploit maturity](https://docs.snyk.io/features/fixing-and-prioritizing-issues/issue-management/evaluating-and-prioritizing-vulnerabilities).
- Provides a detailed path on how vulnerability is introduced to the system.
- Shows summary of the vulnerability together with the remediation advice to fix it.
- If available, provides a link to an interactive lesson explaining the vulnerability on Snyk Learn.

## Extension Configuration

Expand Down
6 changes: 3 additions & 3 deletions media/images/icon-external.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions media/images/icon-lines.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions media/images/learn-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions media/views/common/learn.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.learn {
opacity: 0;
height: 0;
margin-top: 0;

&.show {
margin-top: 6px;
opacity: 1;
height: auto;
transition-duration: 500ms;
transition-property: height, opacity, margin-top;
}

&--link {
margin-left: 3px;
}

&__code {
.learn--link {
color: var(--vscode-foreground);
}
}
}
3 changes: 3 additions & 0 deletions src/snyk/base/modules/baseSnykModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { configuration } from '../../common/configuration/instance';
import { ExperimentService } from '../../common/experiment/services/experimentService';
import { Logger } from '../../common/logger/logger';
import { ContextService, IContextService } from '../../common/services/contextService';
import { LearnService } from '../../common/services/learnService';
import { INotificationService } from '../../common/services/notificationService';
import { IOpenerService, OpenerService } from '../../common/services/openerService';
import { IViewManagerService, ViewManagerService } from '../../common/services/viewManagerService';
Expand Down Expand Up @@ -44,6 +45,7 @@ export default abstract class BaseSnykModule implements IBaseSnykModule {
protected cliDownloadService: CliDownloadService;
protected ossService?: OssService;
protected advisorService?: AdvisorProvider;
protected learnService: LearnService;
protected commandController: CommandController;
protected scanModeService: ScanModeService;
protected ossVulnerabilityCountService: OssVulnerabilityCountService;
Expand Down Expand Up @@ -71,6 +73,7 @@ export default abstract class BaseSnykModule implements IBaseSnykModule {
this.openerService = new OpenerService();
this.scanModeService = new ScanModeService(this.contextService, configuration);
this.loadingBadge = new LoadingBadge();
this.learnService = new LearnService(configuration, Logger);
this.snykApiClient = new SnykApiClient(configuration, vsCodeWorkspace, Logger);
this.falsePositiveApi = new FalsePositiveApi(configuration, vsCodeWorkspace, Logger);
this.snykCodeErrorHandler = new SnykCodeErrorHandler(
Expand Down
14 changes: 14 additions & 0 deletions src/snyk/common/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,17 @@ export type OpenIssueCommandArg = {
export type ReportFalsePositiveCommandArg = {
suggestion: Readonly<completeFileSuggestionType>;
};

export const isCodeIssue = (
_issue: completeFileSuggestionType | OssIssueCommandArg,
issueType: OpenCommandIssueType,
): _issue is completeFileSuggestionType => {
return issueType === OpenCommandIssueType.CodeIssue;
};

export const isOssIssue = (
_issue: completeFileSuggestionType | OssIssueCommandArg,
issueType: OpenCommandIssueType,
): _issue is OssIssueCommandArg => {
return issueType === OpenCommandIssueType.OssVulnerability;
};
2 changes: 2 additions & 0 deletions src/snyk/common/constants/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ export const EXECUTION_PAUSE_INTERVAL = 1000 * 60 * 30; // 30 minutes
export const REFRESH_VIEW_DEBOUNCE_INTERVAL = 200; // 200 milliseconds
// If CONNECTION_ERROR_RETRY_INTERVAL is smaller than EXECUTION_DEBOUNCE_INTERVAL it might get swallowed by the debouncer
export const CONNECTION_ERROR_RETRY_INTERVAL = DEFAULT_SCAN_DEBOUNCE_INTERVAL * 2 + 1000 * 3;

export const SNYK_LEARN_API_CACHE_DURATION_IN_MS = 1000 * 60 * 60 * 24; // 1 day
4 changes: 4 additions & 0 deletions src/snyk/common/messages/learn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const messages = {
getLessonError: 'Failed to get Snyk Learn lesson',
lessonButtonTitle: 'Learn about this vulnerability',
};
125 changes: 125 additions & 0 deletions src/snyk/common/services/learnService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import axios from 'axios';
import { isCodeIssue, isOssIssue, OpenCommandIssueType } from '../../common/commands/types';
import { SNYK_LEARN_API_CACHE_DURATION_IN_MS } from '../../common/constants/general';
import type { completeFileSuggestionType } from '../../snykCode/interfaces';
import { OssIssueCommandArg } from '../../snykOss/views/ossVulnerabilityTreeProvider';
import { IConfiguration } from '../configuration/configuration';
import { ErrorHandler } from '../error/errorHandler';
import { ILog } from '../logger/interfaces';

export type Lesson = {
title: string;
lessonId: string;
ecosystem: string;
url: string;
};

interface LessonLookupParams {
rule: string;
ecosystem: string;
cwes?: string[];
cves?: string[];
}

export class LearnService {
private lessonsCache = new Map<
string,
{
lessons: Lesson[];
expiry: number;
}
>();

constructor(
private readonly configuration: IConfiguration,
private readonly logger: ILog,
private readonly shouldCacheRequests = true,
) {}

static getCodeIssueParams(issue: completeFileSuggestionType): LessonLookupParams {
const idParts = issue.id.split(/\/|%2F/g);

return {
rule: idParts[idParts.length - 1],
ecosystem: idParts[0],
cwes: issue.cwe,
};
}

static getOSSIssueParams(issue: OssIssueCommandArg): LessonLookupParams {
return {
rule: issue.id,
ecosystem: issue.packageManager,
cwes: issue.identifiers?.CWE,
cves: issue.identifiers?.CVE,
};
}

async requestLessons(params: LessonLookupParams) {
const cacheResult = this.lessonsCache.get(params.rule);

if (this.shouldCacheRequests && cacheResult && cacheResult?.expiry > Date.now()) {
return cacheResult.lessons;
} else {
const res = await axios.get<{ lessons: Lesson[] }>('/lessons/lookup-for-cta', {
baseURL: this.snykLearnEndpoint,
params: {
source: 'ide',
rule: params.rule,
ecosystem: params.ecosystem,
cwe: params.cwes?.[0],
cve: params.cves?.[0],
},
});

const lessons = res.data.lessons;

this.lessonsCache.set(params.rule, {
lessons,
expiry: Date.now() + SNYK_LEARN_API_CACHE_DURATION_IN_MS,
});

return lessons;
}
}

async getLesson(
issue: OssIssueCommandArg | completeFileSuggestionType,
issueType: OpenCommandIssueType,
): Promise<Lesson | null> {
try {
let params: LessonLookupParams | null = null;

if (isCodeIssue(issue, issueType)) {
if (!issue.isSecurityType) return null;

params = LearnService.getCodeIssueParams(issue);
} else if (isOssIssue(issue, issueType)) {
// Snyk Learn does not currently deal with licensing issues.
if (issue.license) return null;

params = LearnService.getOSSIssueParams(issue);
} else {
ErrorHandler.handle(new Error(`Issue type "${issueType}" not supported`), this.logger);
return null;
}

console.log(params);

if (!params) {
return null;
}

const lessons = await this.requestLessons(params);

return lessons.length > 0 ? lessons[0] : null;
} catch (err) {
ErrorHandler.handle(err, this.logger, 'Error getting Snyk Learn Lesson');
return null;
}
}

get snykLearnEndpoint(): string {
return `${this.configuration.baseApiUrl}/v1/learn`;
}
}
1 change: 1 addition & 0 deletions src/snyk/common/views/webviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export abstract class WebviewProvider<ViewModel> implements IWebViewProvider<Vie
if (this.panel && this.panel.visible) {
try {
await this.panel.webview.postMessage({ type: 'get' });
await this.panel.webview.postMessage({ type: 'getLesson' });
} catch (e) {
if (!this.panel) return; // can happen due to asynchronicity, ignore such cases
Logger.error(`Failed to restore the '${this.panel.title}' webview.`);
Expand Down
3 changes: 2 additions & 1 deletion src/snyk/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ class SnykExtension extends SnykLib implements IExtension {
this.snykCodeErrorHandler,
new UriAdapter(),
this.codeSettings,
this.learnService,
);

this.advisorService = new AdvisorProvider(this.advisorApiClient, Logger);
Expand All @@ -167,7 +168,7 @@ class SnykExtension extends SnykLib implements IExtension {
this.context,
Logger,
configuration,
new OssSuggestionWebviewProvider(this.context, vsCodeWindow, Logger),
new OssSuggestionWebviewProvider(this.context, vsCodeWindow, Logger, this.learnService),
vsCodeWorkspace,
this.viewManagerService,
this.cliDownloadService,
Expand Down
3 changes: 3 additions & 0 deletions src/snyk/snykCode/codeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { IDE_NAME } from '../common/constants/general';
import { ErrorHandler } from '../common/error/errorHandler';
import { ILog } from '../common/logger/interfaces';
import { Logger } from '../common/logger/logger';
import { LearnService } from '../common/services/learnService';
import { IViewManagerService } from '../common/services/viewManagerService';
import { User } from '../common/user';
import { IWebViewProvider } from '../common/views/webviewProvider';
Expand Down Expand Up @@ -86,6 +87,7 @@ export class SnykCodeService extends AnalysisStatusProvider implements ISnykCode
private readonly errorHandler: ISnykCodeErrorHandler,
private readonly uriAdapter: IUriAdapter,
codeSettings: ICodeSettings,
private readonly learnService: LearnService,
) {
super();
this.analyzer = new SnykCodeAnalyzer(logger, languages, analytics, errorHandler, this.uriAdapter, this.config);
Expand All @@ -107,6 +109,7 @@ export class SnykCodeService extends AnalysisStatusProvider implements ISnykCode
this.logger,
languages,
codeSettings,
this.learnService,
);

this.progress = new Progress(this, viewManagerService, this.workspace);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import { OpenCommandIssueType } from '../../../common/commands/types';
import { IConfiguration } from '../../../common/configuration/configuration';
import {
SNYK_IGNORE_ISSUE_COMMAND,
Expand All @@ -9,6 +10,8 @@ import {
import { SNYK_VIEW_SUGGESTION_CODE } 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 { IWebViewProvider, WebviewProvider } from '../../../common/views/webviewProvider';
Expand Down Expand Up @@ -40,6 +43,7 @@ export class CodeSuggestionWebviewProvider
protected readonly logger: ILog,
private readonly languages: IVSCodeLanguages,
private readonly codeSettings: ICodeSettings,
private readonly learnService: LearnService,
) {
super(context, logger);
}
Expand All @@ -66,6 +70,27 @@ export class CodeSuggestionWebviewProvider
if (!found) this.disposePanel();
}

async postLearnLessonMessage(suggestion: completeFileSuggestionType): Promise<void> {
try {
if (this.panel) {
const lesson = await this.learnService.getLesson(suggestion, OpenCommandIssueType.CodeIssue);
if (lesson) {
void this.panel.webview.postMessage({
type: 'setLesson',
args: { url: `${lesson.url}?loc=ide`, title: learnMessages.lessonButtonTitle },
});
} else {
void this.panel.webview.postMessage({
type: 'setLesson',
args: null,
});
}
}
} catch (e) {
ErrorHandler.handle(e, this.logger, learnMessages.getLessonError);
}
}

async showPanel(suggestion: completeFileSuggestionType): Promise<void> {
try {
await this.focusSecondEditorGroup();
Expand All @@ -88,6 +113,7 @@ export class CodeSuggestionWebviewProvider
this.panel.webview.html = this.getHtmlForWebview(this.panel.webview);

await this.panel.webview.postMessage({ type: 'set', args: suggestion });
void this.postLearnLessonMessage(suggestion);

this.panel.onDidDispose(() => this.onPanelDispose(), null, this.disposables);
this.panel.onDidChangeViewState(() => this.checkVisibility(), undefined, this.disposables);
Expand Down Expand Up @@ -186,6 +212,7 @@ export class CodeSuggestionWebviewProvider
['arrow-right-dark', 'svg'],
['arrow-left-light', 'svg'],
['arrow-right-light', 'svg'],
['learn-icon', 'svg'],
].reduce<Record<string, string>>((accumulator: Record<string, string>, [name, ext]) => {
const uri = this.getWebViewUri('media', 'images', `${name}.${ext}`);
if (!uri) throw new Error('Image missing.');
Expand All @@ -203,6 +230,8 @@ export class CodeSuggestionWebviewProvider
);
const styleUri = this.getWebViewUri('media', 'views', 'snykCode', 'suggestion', 'suggestion.css');
const styleVSCodeUri = this.getWebViewUri('media', 'views', 'common', 'vscode.css');
const learnStyleUri = this.getWebViewUri('media', 'views', 'common', 'learn.css');

const nonce = getNonce();
return `
<!DOCTYPE html>
Expand All @@ -216,6 +245,7 @@ export class CodeSuggestionWebviewProvider
<link href="${styleUri}" rel="stylesheet">
<link href="${styleVSCodeUri}" rel="stylesheet">
<link href="${learnStyleUri}" rel="stylesheet">
</head>
<body>
<div class="suggestion">
Expand All @@ -240,6 +270,10 @@ export class CodeSuggestionWebviewProvider
<img class="icon" src="${images['icon-external']}" /> More info
</div>
</div>
<div class="learn learn__code">
<img class="icon" src="${images['learn-icon']}" />
<a class="learn--link"></a>
</div>
</section>
<section class="delimiter-top" id="labels"></section>
<section class="delimiter-top">
Expand Down
Loading

0 comments on commit 6daa113

Please sign in to comment.