diff --git a/CHANGELOG.md b/CHANGELOG.md index 393f6714a..a4cdc9579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ # Snyk Security Changelog +## [2.6.0] +- Improve UX of AI fixes by adding previews and options ## [2.4.1] - updated the language server protocol version to 11 to support global ignores diff --git a/media/images/icon-external.svg b/media/images/icon-external.svg index 59307674f..84ad7ed08 100644 --- a/media/images/icon-external.svg +++ b/media/images/icon-external.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/media/views/common/vscode.scss b/media/views/common/vscode.scss index f8a4ba86c..cef44dac2 100644 --- a/media/views/common/vscode.scss +++ b/media/views/common/vscode.scss @@ -9,6 +9,8 @@ --input-padding-horizontal: 4px; --input-margin-vertical: 4px; --input-margin-horizontal: 0; + --button-padding-horizontal: 16px; + --button-padding-vertical: 6px; } body { @@ -25,8 +27,8 @@ ul { padding-left: var(--container-paddding); } -body > *, -form > * { +body>*, +form>* { margin-block-start: var(--input-margin-vertical); margin-block-end: var(--input-margin-vertical); } @@ -35,8 +37,16 @@ form > * { outline-color: var(--vscode-focusBorder) !important; } -a { +p { + margin-top: 0; + margin-bottom: 2rem; +} + +a, +.link { + cursor: pointer; color: var(--vscode-textLink-foreground); + fill: currentColor; } a:hover, @@ -51,8 +61,7 @@ code { button { border: none; - padding: var(--input-padding-vertical) var(--input-padding-horizontal); - width: 100%; + padding: var(--button-padding-vertical) var(--button-padding-horizontal); text-align: center; outline: 1px solid transparent; outline-offset: 2px !important; diff --git a/media/views/snykCode/suggestion/suggestion.scss b/media/views/snykCode/suggestion/suggestion.scss index 04555b5c8..16ce00b3f 100644 --- a/media/views/snykCode/suggestion/suggestion.scss +++ b/media/views/snykCode/suggestion/suggestion.scss @@ -1,6 +1,15 @@ @import '../../common/variables'; @import '../../common/webview'; +button, +.button { + border-radius: 3px; +} + +body { + height: 100% +} + .row { display: flex; flex-direction: row; @@ -23,7 +32,7 @@ .suggestion { position: relative; - display: flex; + display: inline-flex; flex-direction: column; width: 100%; height: 100%; @@ -43,6 +52,39 @@ line-height: 1.6; } + +.ai-fix { + padding-bottom: 0; +} + +.ai-fix p { + margin-bottom: 0; +} + +.sn-fix-wrapper { + padding: 2rem; + margin-top:1rem; + background-color: var(--vscode-editor-background); + border-radius: .8rem; + overflow: auto; +} + +.generate-ai-fix { + width: auto; + padding: 8px 16px; +} + +.low-opacity { + opacity: .5; + font-weight: 400; +} + +.suggestion-details-content { + padding-bottom: 64px; +} + +.sn-apply-fix {} + .suggestion-details { height: auto; transition: height 400ms ease-out; @@ -73,19 +115,6 @@ padding-top: 0; } -.read-more-btn { - background: none; - border: 2px solid var(--vscode-textLink-foreground); - color: var(--vscode-textLink-foreground); - cursor: pointer; - width: 130px; - border-radius: 3px; - -}.read-more-btn:hover, .read-more-btn:active { - background: var(--vscode-textLink-foreground); - color: var(--vscode-button-foreground); -} - .cwe-list { display: inline-block; margin: 0; @@ -153,12 +182,14 @@ color: var(--vscode-icon-foreground); } -.vscode-dark .removed, .vscode-high-contrast:not(.vscode-high-contrast-light) .removed { +.vscode-dark .removed, +.vscode-high-contrast:not(.vscode-high-contrast-light) .removed { background-color: #542426; color: #fff; } -.vscode-dark .added, .vscode-high-contrast:not(.vscode-high-contrast-light) .added { +.vscode-dark .added, +.vscode-high-contrast:not(.vscode-high-contrast-light) .added { background-color: #1C4428; color: #fff; } @@ -189,11 +220,11 @@ } .arrow { + cursor: pointer; display: inline-block; width: 20px; height: 20px; padding: 4px; - cursor: pointer; border-radius: 4px; text-align: center; line-height: 1; @@ -213,37 +244,38 @@ text-align: center; } -#example { +.example { width: 100%; border: 1px solid var(--vscode-input-border); border-radius: 3px; line-height: 1.5; background-color: var(--vscode-editor-background); + margin-bottom: 1rem; } -.example-line.removed { +.code-line.removed { background-color: rgb(255, 215, 213); } -.example-line.removed::before { +.code-line.removed::before { content: "-"; position: absolute; padding: 0 4px; line-height: 1; } -.example-line.added { +.code-line.added { background-color: rgb(204, 255, 216); } -.example-line.added::before { +.code-line.added::before { content: "+"; position: absolute; padding: 0 4px; line-height: 1; } -.example-line>code { +.code-line>code { display: block; padding-left: 30px; white-space: pre-wrap; @@ -288,12 +320,296 @@ display: flex; } -.actions .button { - margin: 0 0 2rem; - flex: 0 0 30%; +.report-fp-actions { + margin-left: auto; +} + +.tabs-nav { + margin: 21px 0 -21px; +} + +.tab-item { + cursor: pointer; + display: inline-block; + padding: 5px 10px; + border-bottom: 1px solid transparent; + font-size: 1.1rem; + color: var(--vscode-foreground); + text-transform: uppercase; +} + +.tab-item:hover { + +} + +.tab-item.is-selected { + border-bottom: 3px solid var(--vscode-focusBorder); +} + +.tab-content { + display: none; +} + +.tab-content.is-selected { + display: block; +} + +.is-external { + padding-right: 12px; + background: url("../../../images/icon-external.svg") no-repeat top right; +} + +.button.secondary { + cursor: pointer; + width: auto; + padding: 6px 16px; + border: 1px solid var(--vscode-textLink-foreground); border-radius: 3px; + background: none; + font-size: 1.4rem; + line-height: 1; + color: var(--vscode-textLink-foreground); } -.report-fp-actions { - margin-left: auto; +.button.secondary:hover, +.button.secondary:active { + background: var(--vscode-button-hoverBackground); + border-color: var(--vscode-button-hoverBackground); + color: var(--vscode-button-foreground); +} + +.sn-readmore { + margin-top: 1.6rem; +} + + + + +.sn-loading { + display: flex; +} + +.sn-loading svg { + inline-size: 6rem; + block-size: auto +} + +.sn-loading-wrapper { + position: relative; + display: flex; + flex-direction: column; + width: 100%; +} + +.sn-loading-message { + opacity: 0; + position: absolute; + width: 100%; + padding-left: 16px; + margin-bottom: 8px; + font-size: 14px; +} + +.sn-loading-title { + font-weight: 600; + line-height: 1.5; +} + +.sn-loading-description { + margin-bottom: 0; + opacity: .75 +} + + +.sn-msg-1 { + animation: reduce 4s ease-in; +} + +.sn-msg-2 { + animation: reduce 4s ease-in; + animation-delay: 4s; +} + +.sn-msg-3 { + animation: reduce 4s ease-in; + animation-delay: 8s; +} + +.sn-msg-4 { + animation: inference 4s ease-in infinite; + animation-delay: 12s; +} + +.suggestion-actions { + position: fixed; + bottom: 0; + width: 100%; + background-color: var(--vscode-editor-background); + background-image: linear-gradient(45deg, rgba(255,255,255,0.075), rgba(255,255,255,0.075)); + box-shadow: 0 -1px 3px rgba(0,0,0,.05); +} + +#s0 { + animation: s0ani 3000ms linear infinite; +} + +#l1 { + animation: l1ani 3000ms linear infinite; +} + +#l2 { + animation: l2ani 3000ms linear infinite; +} + +#l3 { + animation: l3ani 3000ms linear infinite; +} + +#b1 { + animation: b1ani 3000ms linear infinite; +} + +#b2 { + animation: b2ani 3000ms linear infinite; +} + +#b3 { + animation: b3ani 3000ms linear infinite; +} + +@keyframes s0ani { + 0% { + transform: translate(50%, -15%); + } + + 100% { + transform: translate(50%, 115%); + } +} + +@keyframes l1ani { + + 0%, + 23% { + fill: rgba(255, 255, 255, 0.2); + } + + 40%, + 100% { + fill: rgba(249, 122, 153, 0.6); + } +} + +@keyframes l2ani { + + 0%, + 40% { + fill: rgba(255, 255, 255, 0.2); + } + + 56%, + 100% { + fill: rgba(249, 122, 153, 0.6); + } +} + +@keyframes l3ani { + + 0%, + 56% { + fill: rgba(255, 255, 255, 0.2); + } + + 72%, + 100% { + fill: rgba(67, 181, 154, 0.6); + } +} + +@keyframes b1ani { + + 0%, + 8% { + opacity: 0; + transform: scale(1, 1); + } + + 33% { + transform: translate(-10%, -18%) scale(1.6, 1.6); + } + + 53%, + 100% { + opacity: 1; + transform: scale(1, 1); + } +} + +@keyframes b2ani { + + 0%, + 36% { + opacity: 0; + transform: scale(1, 1); + } + + 50% { + transform: translate(-20%, -18%) scale(1.4, 1.4); + } + + 60%, + 100% { + opacity: 1; + transform: scale(1, 1); + } +} + +@keyframes b3ani { + + 0%, + 54% { + opacity: 0; + transform: scale(1, 1); + } + + 66% { + transform: translate(-10%, -27%) scale(1.4, 1.4); + } + + 76%, + 100% { + opacity: 1; + transform: scale(1, 1); + } +} + + +@keyframes reduce { + + 15%, + 85% { + opacity: 1 + } + + 86%, + 100%, + 0% { + opacity: 0; + } +} + +@keyframes inference { + + 0%, + 25%, + 100% { + opacity: 1 + } +} + + +@media (max-width: 480px) { + .wide { + display: none; + } } diff --git a/package-lock.json b/package-lock.json index e526745d5..61a3bf0ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@sentry/tracing": "^6.19.7", "@snyk/code-client": "^4.23.5", "axios": "^1.6.7", + "diff": "^5.2.0", "glob": "^9.3.5", "he": "^1.2.0", "htmlparser2": "^7.2.0", @@ -34,6 +35,7 @@ "devDependencies": { "@amplitude/ampli": "^1.29.0", "@types/babel__traverse": "^7.12.2", + "@types/diff": "^5.0.9", "@types/find-package-json": "^1.2.2", "@types/glob": "^8.1.0", "@types/he": "^1.2.3", @@ -1689,6 +1691,12 @@ "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==", "dev": true }, + "node_modules/@types/diff": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.0.9.tgz", + "integrity": "sha512-RWVEhh/zGXpAVF/ZChwNnv7r4rvqzJ7lYNSmZSVTxjV0PBLf6Qu7RNg+SUtkpzxmiNkjCx0Xn2tPp7FIkshJwQ==", + "dev": true + }, "node_modules/@types/find-package-json": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/find-package-json/-/find-package-json-1.2.2.tgz", @@ -3461,10 +3469,9 @@ } }, "node_modules/diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true, + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", "engines": { "node": ">=0.3.1" } @@ -6114,6 +6121,15 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/mocha/node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/mocha/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -10287,6 +10303,12 @@ "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==", "dev": true }, + "@types/diff": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.0.9.tgz", + "integrity": "sha512-RWVEhh/zGXpAVF/ZChwNnv7r4rvqzJ7lYNSmZSVTxjV0PBLf6Qu7RNg+SUtkpzxmiNkjCx0Xn2tPp7FIkshJwQ==", + "dev": true + }, "@types/find-package-json": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/find-package-json/-/find-package-json-1.2.2.tgz", @@ -11614,10 +11636,9 @@ "dev": true }, "diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==" }, "dir-glob": { "version": "3.0.1", @@ -13579,6 +13600,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true + }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", diff --git a/package.json b/package.json index 00222620d..56bc7339b 100644 --- a/package.json +++ b/package.json @@ -260,12 +260,12 @@ }, { "view": "snyk.views.welcome", - "contents": "Welcome to Snyk for Visual Studio Code. šŸ‘‹\nšŸ‘‰ Please wait, the extension is loading...", + "contents": "šŸ‘‹ Welcome to Snyk for Visual Studio Code. \nā±ļø Please wait, the extension is loading...", "when": "!snyk:error && !snyk:initialized" }, { "view": "snyk.views.welcome", - "contents": "Welcome to Snyk for Visual Studio Code. šŸ‘‹\nšŸ‘‰ Connect with Snyk to start your first analysis!\nWhen scanning folder files, Snyk may automatically execute code such as invoking the package manager to get dependency information. You should only scan projects you trust. [More info](https://docs.snyk.io/ide-tools/visual-studio-code-extension/workspace-trust)\n[Trust workspace and connect](command:snyk.initiateLogin 'Connect with Snyk')\nBy connecting your account with Snyk, you agree to the Snyk [Privacy Policy](https://snyk.io/policies/privacy), and the Snyk [Terms of Service](https://snyk.io/policies/terms-of-service).", + "contents": "šŸ‘‹ Welcome to Snyk for Visual Studio Code. \nšŸ‘‰ Connect with Snyk to start your first analysis!\nWhen scanning folder files, Snyk may automatically execute code such as invoking the package manager to get dependency information. You should only scan projects you trust. [More info](https://docs.snyk.io/ide-tools/visual-studio-code-extension/workspace-trust)\n[Trust workspace and connect](command:snyk.initiateLogin 'Connect with Snyk')\nBy connecting your account with Snyk, you agree to the Snyk [Privacy Policy](https://snyk.io/policies/privacy), and the Snyk [Terms of Service](https://snyk.io/policies/terms-of-service).", "when": "!snyk:error && snyk:initialized && !snyk:loggedIn" }, { @@ -381,6 +381,7 @@ "devDependencies": { "@amplitude/ampli": "^1.29.0", "@types/babel__traverse": "^7.12.2", + "@types/diff": "^5.0.9", "@types/find-package-json": "^1.2.2", "@types/glob": "^8.1.0", "@types/he": "^1.2.3", @@ -420,6 +421,7 @@ "@sentry/tracing": "^6.19.7", "@snyk/code-client": "^4.23.5", "axios": "^1.6.7", + "diff": "^5.2.0", "glob": "^9.3.5", "he": "^1.2.0", "htmlparser2": "^7.2.0", diff --git a/src/snyk/common/constants/commands.ts b/src/snyk/common/constants/commands.ts index 7ca96feff..ee3420077 100644 --- a/src/snyk/common/constants/commands.ts +++ b/src/snyk/common/constants/commands.ts @@ -23,6 +23,7 @@ export const SNYK_LOGIN_COMMAND = 'snyk.login'; export const SNYK_WORKSPACE_SCAN_COMMAND = 'snyk.workspace.scan'; export const SNYK_TRUST_WORKSPACE_FOLDERS_COMMAND = 'snyk.trustWorkspaceFolders'; export const SNYK_GET_ACTIVE_USER = 'snyk.getActiveUser'; +export const SNYK_CODE_FIX_DIFFS_COMMAND = 'snyk.code.fixDiffs'; // custom Snyk constants used in commands export const SNYK_CONTEXT_PREFIX = 'snyk:'; diff --git a/src/snyk/common/languageServer/types.ts b/src/snyk/common/languageServer/types.ts index e598be7c9..ea5219a0e 100644 --- a/src/snyk/common/languageServer/types.ts +++ b/src/snyk/common/languageServer/types.ts @@ -4,6 +4,8 @@ export enum ScanProduct { InfrastructureAsCode = 'iac', } +export type InProgress = 'inProgress'; + export enum ScanStatus { InProgress = 'inProgress', Success = 'success', @@ -48,6 +50,7 @@ export type CodeIssueData = { rows: Point; isSecurityType: boolean; priorityScore: number; + hasAIFix: boolean; }; export type ExampleCommitFix = { @@ -115,3 +118,8 @@ export type IacIssueData = { resolve?: string; references?: string[]; }; + +export type AutofixUnifiedDiffSuggestion = { + fixId: string; + unifiedDiffsPerFile: { [key: string]: string }; +}; diff --git a/src/snyk/common/services/learnService.ts b/src/snyk/common/services/learnService.ts index 259bd9bec..fde6fd070 100644 --- a/src/snyk/common/services/learnService.ts +++ b/src/snyk/common/services/learnService.ts @@ -2,7 +2,7 @@ import { SNYK_GET_LESSON_COMMAND } from '../constants/commands'; import { CodeIssueData, Issue } from '../languageServer/types'; import { IVSCodeCommands } from '../vscode/commands'; -type Lesson = { +export type Lesson = { url: string; title: string; }; diff --git a/src/snyk/common/views/analysisTreeNodeProvider.ts b/src/snyk/common/views/analysisTreeNodeProvider.ts index 990602efa..8cea9f592 100644 --- a/src/snyk/common/views/analysisTreeNodeProvider.ts +++ b/src/snyk/common/views/analysisTreeNodeProvider.ts @@ -36,16 +36,6 @@ export abstract class AnalysisTreeNodeProvider extends TreeNodeProvider { return 0; }; - protected getDurationTreeNode(): TreeNode { - const ts = new Date(this.statusProvider.lastAnalysisTimestamp); - const time = ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - const day = ts.toLocaleDateString([], { year: '2-digit', month: '2-digit', day: '2-digit' }); - - return new TreeNode({ - text: messages.duration(time, day), - }); - } - protected getNoSeverityFiltersSelectedTreeNode(): TreeNode | null { const anyFilterEnabled = Object.values(this.configuration.severityFilter).find(enabled => !!enabled); if (anyFilterEnabled) { @@ -81,6 +71,4 @@ export abstract class AnalysisTreeNodeProvider extends TreeNodeProvider { }, }); } - - protected abstract getFilteredIssues(issues: readonly unknown[]): readonly unknown[]; } diff --git a/src/snyk/common/views/issueTreeProvider.ts b/src/snyk/common/views/issueTreeProvider.ts index b55fe4b01..d81bc3b10 100644 --- a/src/snyk/common/views/issueTreeProvider.ts +++ b/src/snyk/common/views/issueTreeProvider.ts @@ -1,4 +1,4 @@ -import _ from 'lodash'; +import _, { flatten } from 'lodash'; import * as vscode from 'vscode'; // todo: invert dependency import { IConfiguration } from '../../common/configuration/configuration'; import { Issue, IssueSeverity } from '../../common/languageServer/types'; @@ -75,8 +75,7 @@ export abstract class ProductIssueTreeProvider extends AnalysisTreeNodeProvid ]; } - const [resultNodes, nIssues] = this.getResultNodes(); - nodes.push(...resultNodes); + nodes.push(...this.getResultNodes()); const folderResults = Array.from(this.productService.result.values()); const allFailed = folderResults.every(folderResult => folderResult instanceof Error); @@ -86,20 +85,42 @@ export abstract class ProductIssueTreeProvider extends AnalysisTreeNodeProvid nodes.sort(this.compareNodes); - const topNodes = [ + const topNodes: (TreeNode | null)[] = [ new TreeNode({ - text: this.getIssueFoundText(nIssues), + text: this.getIssueFoundText(this.getTotalIssueCount()), }), - this.getDurationTreeNode(), + this.getFixableIssuesNode(this.getFixableCount()), this.getNoSeverityFiltersSelectedTreeNode(), ]; + nodes.unshift(...topNodes.filter((n): n is TreeNode => n !== null)); return nodes; } - getResultNodes(): [TreeNode[], number] { + getFixableIssuesNode(_fixableIssueCount: number): TreeNode | null { + return null; // optionally overridden by products + } + + getFilteredIssues(): Issue[] { + const folderResults = Array.from(this.productService.result.values()); + const successfulResults = flatten(folderResults.filter((result): result is Issue[] => Array.isArray(result))); + return this.filterIssues(successfulResults); + } + + getTotalIssueCount(): number { + return this.getFilteredIssues().length; + } + + getFixableCount(): number { + return this.getFilteredIssues().filter(issue => this.isFixableIssue(issue)).length; + } + + isFixableIssue(_issue: Issue) { + return false; // optionally overridden by products + } + + getResultNodes(): TreeNode[] { const nodes: TreeNode[] = []; - let totalVulnCount = 0; for (const result of this.productService.result.entries()) { const folderPath = result[0]; @@ -133,7 +154,6 @@ export abstract class ProductIssueTreeProvider extends AnalysisTreeNodeProvid const issueNodes = filteredIssues.map(issue => { fileSeverityCounts[issue.severity] += 1; - totalVulnCount++; folderVulnCount++; const issueRange = this.getIssueRange(issue); @@ -209,7 +229,7 @@ export abstract class ProductIssueTreeProvider extends AnalysisTreeNodeProvid } } - return [nodes, totalVulnCount]; + return nodes; } protected getIssueFoundText(nIssues: number): string { @@ -220,12 +240,6 @@ export abstract class ProductIssueTreeProvider extends AnalysisTreeNodeProvid return `${dir} - ${issueCount} issue${issueCount === 1 ? '' : 's'}`; } - // todo: Obsolete. Remove after OSS scans migration to LS - protected getFilteredIssues(diagnostics: readonly unknown[]): readonly unknown[] { - // Diagnostics are already filtered by the analyzer - return diagnostics; - } - static getHighestSeverity(counts: ISeverityCounts): IssueSeverity { for (const s of [IssueSeverity.Critical, IssueSeverity.High, IssueSeverity.Medium, IssueSeverity.Low]) { if (counts[s]) return s; diff --git a/src/snyk/snykCode/utils/patchUtils.ts b/src/snyk/snykCode/utils/patchUtils.ts new file mode 100644 index 000000000..e798f54e4 --- /dev/null +++ b/src/snyk/snykCode/utils/patchUtils.ts @@ -0,0 +1,42 @@ +import { IVSCodeLanguages } from '../../common/vscode/languages'; +import { DecorationOptions } from '../../common/vscode/types'; + +// Supports Unified Diff Format +export function generateDecorationOptions(patch: string, languages: IVSCodeLanguages): DecorationOptions[] { + const codeLines = patch.split('\n'); + + // the first two lines are the file names + codeLines.shift(); + codeLines.shift(); + + const decorationOptions: DecorationOptions[] = []; + let currentLine = -1; + + for (const line of codeLines) { + if (line.startsWith('@@ ')) { + // format is -original, +new + // @@ -start,count +start,count @@ + // counts are considered optional + // we only care about the start line for the new file + const [, , added] = line.split(' '); + const [startLineValue] = added.split(','); + + // unified diff line numbers start from 1 not 0 + // vscode.Range starts from 0 not 1 + currentLine = parseInt(startLineValue) - 1; + } else { + if (line.startsWith('+')) { + const range = languages.createRange(currentLine, 0, currentLine, line.length - 1); + + decorationOptions.push({ range }); + currentLine++; + } else if (line.startsWith('-')) { + continue; + } else { + currentLine++; + } + } + } + + return decorationOptions; +} diff --git a/src/snyk/snykCode/views/issueTreeProvider.ts b/src/snyk/snykCode/views/issueTreeProvider.ts index 8ccd9662d..d8b91ef84 100644 --- a/src/snyk/snykCode/views/issueTreeProvider.ts +++ b/src/snyk/snykCode/views/issueTreeProvider.ts @@ -10,6 +10,7 @@ import { IVSCodeLanguages } from '../../common/vscode/languages'; import { messages } from '../messages/analysis'; import { IssueUtils } from '../utils/issueUtils'; import { CodeIssueCommandArg } from './interfaces'; +import { TreeNode } from '../../common/views/treeNode'; export class IssueTreeProvider extends ProductIssueTreeProvider { constructor( @@ -34,6 +35,7 @@ export class IssueTreeProvider extends ProductIssueTreeProvider { // The title in the tree is taken from the title for vulnerabilities and from the message for quality rules getIssueTitle(issue: Issue): string { + const fixIcon = issue.additionalData.hasAIFix ? 'āš”ļø' : ''; const issueTitle = issue.additionalData.isSecurityType ? issue.title.split(':')[0] : issue.additionalData.message.split('.')[0]; @@ -43,7 +45,7 @@ export class IssueTreeProvider extends ProductIssueTreeProvider { prefixIgnored = '[ Ignored ] '; } - return prefixIgnored + issueTitle; + return fixIcon + prefixIgnored + issueTitle; } getIssueRange(issue: Issue): Range { @@ -67,4 +69,20 @@ export class IssueTreeProvider extends ProductIssueTreeProvider { ], }; } + + isFixableIssue(issue: Issue): boolean { + return issue.additionalData.hasAIFix; + } + + getFixableIssuesNode(fixableIssueCount: number): TreeNode { + return new TreeNode({ + text: this.getAIFixableIssuesText(fixableIssueCount), + }); + } + + private getAIFixableIssuesText(issuesCount: number): string { + return issuesCount > 0 + ? `āš”ļø ${issuesCount} ${issuesCount === 1 ? 'vulnerability' : 'vulnerabilities'} can be fixed by Snyk DeepCode AI` + : 'There are no vulnerabilities fixable by Snyk DeepCode AI'; + } } diff --git a/src/snyk/snykCode/views/securityIssueTreeProvider.ts b/src/snyk/snykCode/views/securityIssueTreeProvider.ts index cfec68524..89ed80ca8 100644 --- a/src/snyk/snykCode/views/securityIssueTreeProvider.ts +++ b/src/snyk/snykCode/views/securityIssueTreeProvider.ts @@ -39,8 +39,10 @@ export default class CodeSecurityIssueTreeProvider extends IssueTreeProvider { } protected getIssueFoundText(nIssues: number): string { - return `Snyk found ${ - !nIssues ? 'no vulnerabilities! āœ…' : `${nIssues} ${nIssues === 1 ? 'vulnerability' : 'vulnerabilities'}` - }`; + if (nIssues > 0) { + return nIssues === 1 ? `${nIssues} vulnerability found by Snyk` : `āœ‹ ${nIssues} vulnerabilities found by Snyk`; + } else { + return 'āœ… Congrats! No vulnerabilities found!'; + } } } diff --git a/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewProvider.ts b/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewProvider.ts index bf500c4ef..ea972e0fb 100644 --- a/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewProvider.ts +++ b/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewProvider.ts @@ -1,19 +1,21 @@ import _ from 'lodash'; +import { relative } from 'path'; +import { applyPatch } from 'diff'; import { marked } from 'marked'; import * as vscode from 'vscode'; import { + SNYK_CODE_FIX_DIFFS_COMMAND, SNYK_IGNORE_ISSUE_COMMAND, SNYK_OPEN_BROWSER_COMMAND, SNYK_OPEN_LOCAL_COMMAND, } from '../../../common/constants/commands'; import { SNYK_VIEW_SUGGESTION_CODE } from '../../../common/constants/views'; import { ErrorHandler } from '../../../common/error/errorHandler'; -import { CodeIssueData, ExampleCommitFix, Issue, Marker, Point } from '../../../common/languageServer/types'; +import { AutofixUnifiedDiffSuggestion, CodeIssueData, Issue } from '../../../common/languageServer/types'; 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 { IVSCodeLanguages } from '../../../common/vscode/languages'; @@ -23,26 +25,13 @@ import { WEBVIEW_PANEL_QUALITY_TITLE, WEBVIEW_PANEL_SECURITY_TITLE } from '../.. import { messages as errorMessages } from '../../messages/error'; import { getAbsoluteMarkerFilePath } from '../../utils/analysisUtils'; import { encodeExampleCommitFixes } from '../../utils/htmlEncoder'; +import { generateDecorationOptions } from '../../utils/patchUtils'; import { IssueUtils } from '../../utils/issueUtils'; import { ICodeSuggestionWebviewProvider } from '../interfaces'; - -type Suggestion = { - id: string; - message: string; - severity: string; - leadURL?: string; - rule: string; - repoDatasetSize: number; - exampleCommitFixes: ExampleCommitFix[]; - cwe: string[]; - title: string; - text: string; - isSecurityType: boolean; - uri: string; - markers?: Marker[]; - cols: Point; - rows: Point; -}; +import { readFileSync } from 'fs'; +import { TextDocument } from '../../../common/vscode/types'; +import { Suggestion, SuggestionMessage } from './types'; +import { WebviewPanelSerializer } from '../../../snykCode/views/webviewPanelSerializer'; export class CodeSuggestionWebviewProvider extends WebviewProvider> @@ -73,17 +62,21 @@ export class CodeSuggestionWebviewProvider return this.issue?.id; } + private async postSuggestMessage(message: SuggestionMessage): Promise { + await this.panel?.webview.postMessage(message); + } + async postLearnLessonMessage(issue: Issue): Promise { try { if (this.panel) { const lesson = await this.learnService.getCodeLesson(issue); if (lesson) { - void this.panel.webview.postMessage({ + void this.postSuggestMessage({ type: 'setLesson', args: { url: lesson.url, title: learnMessages.lessonButtonTitle }, }); } else { - void this.panel.webview.postMessage({ + void this.postSuggestMessage({ type: 'setLesson', args: null, }); @@ -124,7 +117,7 @@ export class CodeSuggestionWebviewProvider 'snyk-code.svg', ); - void this.panel.webview.postMessage({ type: 'set', args: this.mapToModel(issue) }); + void this.postSuggestMessage({ type: 'set', args: this.mapToModel(issue) }); void this.postLearnLessonMessage(issue); this.issue = issue; @@ -139,7 +132,11 @@ export class CodeSuggestionWebviewProvider this.panel.onDidDispose(() => this.onPanelDispose(), null, this.disposables); this.panel.onDidChangeViewState(() => this.checkVisibility(), undefined, this.disposables); // Handle messages from the webview - this.panel.webview.onDidReceiveMessage(msg => this.handleMessage(msg), undefined, this.disposables); + this.panel.webview.onDidReceiveMessage( + (msg: SuggestionMessage) => this.handleMessage(msg), + undefined, + this.disposables, + ); } disposePanel(): void { @@ -150,60 +147,126 @@ export class CodeSuggestionWebviewProvider super.onPanelDispose(); } + private getWorkspaceFolderPath(filePath: string) { + // get the workspace folders + // look at the filepath and identify the folder that contains the filepath + for (const folderPath of this.workspace.getWorkspaceFolders()) { + if (filePath.startsWith(folderPath)) { + return folderPath; + } + } + throw new Error(`Unable to find workspace for: ${filePath}`); + } + private mapToModel(issue: Issue): Suggestion { const parsedDetails = marked.parse(issue.additionalData.text) as string; + return { id: issue.id, title: issue.title, - uri: issue.filePath, severity: _.capitalize(issue.severity), ...issue.additionalData, text: parsedDetails, + hasAIFix: issue.additionalData.hasAIFix, + filePath: issue.filePath, }; } - private async handleMessage(message: any) { + private async handleMessage(message: SuggestionMessage) { try { - const { type, args } = message; - switch (type) { + switch (message.type) { case 'openLocal': { - const { uri, cols, rows, suggestionUri } = args as { - uri: string; - cols: [number, number]; - rows: [number, number]; - suggestionUri: string; - }; + const { uri, cols, rows, suggestionUri } = message.args; const localUriPath = getAbsoluteMarkerFilePath(this.workspace, uri, suggestionUri); const localUri = vscode.Uri.file(localUriPath); const range = IssueUtils.createVsCodeRangeFromRange(rows, cols, this.languages); await vscode.commands.executeCommand(SNYK_OPEN_LOCAL_COMMAND, localUri, range); break; } + case 'openBrowser': { - const { url } = args as { url: string }; + const { url } = message.args; await vscode.commands.executeCommand(SNYK_OPEN_BROWSER_COMMAND, url); break; } + case 'ignoreIssue': { - const { lineOnly, message, rule, uri, cols, rows } = args as { - lineOnly: boolean; - message: string; - rule: string; - uri: string; - cols: [number, number]; - rows: [number, number]; - }; + const { lineOnly, rule, uri, cols, rows } = message.args; const vscodeUri = vscode.Uri.file(uri); const range = IssueUtils.createVsCodeRangeFromRange(rows, cols, this.languages); await vscode.commands.executeCommand(SNYK_IGNORE_ISSUE_COMMAND, { uri: vscodeUri, - matchedIssue: { message, range }, + matchedIssue: { + message: message.args.message, + range, + }, ruleId: rule, isFileIgnore: !lineOnly, }); this.panel?.dispose(); break; } + + case 'getAutofixDiffs': { + this.logger.info('Generating fixes'); + + const { suggestion } = message.args; + try { + const filePath = suggestion.filePath; + const folderPath = this.getWorkspaceFolderPath(filePath); + const relativePath = relative(folderPath, filePath); + + const issueId = suggestion.id; + + const diffs: AutofixUnifiedDiffSuggestion[] = await vscode.commands.executeCommand( + SNYK_CODE_FIX_DIFFS_COMMAND, + folderPath, + relativePath, + issueId, + ); + if (diffs.length === 0) { + throw Error('Unable to generate a relevant fix'); + } + + void this.postSuggestMessage({ type: 'setAutofixDiffs', args: { suggestion, diffs } }); + } catch (error) { + void this.postSuggestMessage({ type: 'setAutofixError', args: { suggestion } }); + } + + break; + } + + case 'applyGitDiff': { + const { patch, filePath } = message.args; + + const fileContent = readFileSync(filePath, 'utf8'); + const patchedContent = applyPatch(fileContent, patch); + + if (!patchedContent) { + throw Error('Failed to apply patch'); + } + const edit = new vscode.WorkspaceEdit(); + + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.path === filePath); + + if (!editor) { + throw Error(`Editor with file not found: ${filePath}`); + } + + const editorEndLine = editor.document.lineCount; + edit.replace(vscode.Uri.parse(filePath), new vscode.Range(0, 0, editorEndLine, 0), patchedContent); + + const success = await vscode.workspace.applyEdit(edit); + if (!success) { + throw Error('Failed to apply edit to workspace'); + } + + this.highlightAddedCode(filePath, patch); + this.setupCloseOnSave(filePath); + + break; + } + default: { throw new Error('Unknown message type'); } @@ -213,6 +276,65 @@ export class CodeSuggestionWebviewProvider } } + private setupCloseOnSave(filePath: string) { + vscode.workspace.onDidSaveTextDocument((e: TextDocument) => { + if (e.uri.path == filePath) { + this.panel?.dispose(); + } + }); + } + + private highlightAddedCode(filePath: string, diffData: string) { + const highlightDecoration = vscode.window.createTextEditorDecorationType({ + // seems to work well with both dark and light backgrounds + backgroundColor: 'rgba(0,255,0,0.3)', + }); + + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.path === filePath); + if (!editor) { + return; // No open editor found with the target file + } + + const decorationOptions = generateDecorationOptions(diffData, this.languages); + if (decorationOptions.length === 0) { + return; + } + + editor.setDecorations(highlightDecoration, decorationOptions); + + const firstLine = decorationOptions[0].range.start.line; + + // scroll to first added line + const line = editor.document.lineAt(firstLine); + const range = line.range; + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + + // remove highlight on any of: + // - user types + // - saves the doc + // - after an amount of time + + const removeHighlights = () => { + editor.setDecorations(highlightDecoration, []); + listeners.forEach(listener => { + if (listener instanceof vscode.Disposable) listener.dispose(); + else clearTimeout(listener); + }); + }; + + const documentEventHandler = (document: TextDocument) => { + if (document.uri.path == filePath) { + removeHighlights(); + } + }; + + const listeners = [ + setTimeout(removeHighlights, 30000), + vscode.workspace.onDidSaveTextDocument(documentEventHandler), + vscode.workspace.onDidChangeTextDocument(e => documentEventHandler(e.document)), + ]; + } + private getTitle(issue: Issue): string { return issue.additionalData.isSecurityType ? WEBVIEW_PANEL_SECURITY_TITLE : WEBVIEW_PANEL_QUALITY_TITLE; } @@ -279,58 +401,132 @@ export class CodeSuggestionWebviewProvider
- +
- -
-
-
-
-
-
-
- -
+
+
+
+ +
-
-
- This issue was fixed by projects. Here are examples: -
-
- There are no fix examples for this issue. +
+

āš” Fix this issue by generating a solution using Snyk DeepCode AI

+ +
+ + + +
+
+ + + +
-
-
-
+
+ +
+
+ +
+

Community fixes

+

+ This type of vulnerability was fixed in open source projects. Here are examples: +

+
+ There are no fix examples for this issue. +
+
+
+ + +
+
+ + + + + + Example 1/ + + + + + +
+
+
+
+
+ +
+
+
+
+
+ +
- - + +
diff --git a/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewScript.ts b/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewScript.ts index 4683022f7..197fced52 100644 --- a/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewScript.ts +++ b/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewScript.ts @@ -6,7 +6,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /// -// eslint-disable-next-line @typescript-eslint/no-unused-vars,@typescript-eslint/no-explicit-any declare const acquireVsCodeApi: any; // This script will be run within the webview itself @@ -51,21 +50,120 @@ declare const acquireVsCodeApi: any; title: string; text: string; isSecurityType: boolean; - uri: string; markers?: Marker[]; cols: Point; rows: Point; priorityScore: number; + hasAIFix: boolean; + diffs: AutofixUnifiedDiffSuggestion[]; + filePath: string; }; + type CurrentSeverity = { value: number; text: string; }; + type AutofixUnifiedDiffSuggestion = { + fixId: string; + unifiedDiffsPerFile: { [key: string]: string }; + }; + + type OpenLocalMessage = { + type: 'openLocal'; + args: { + uri: string; + cols: [number, number]; + rows: [number, number]; + suggestionUri: string; + }; + }; + + type IgnoreIssueMessage = { + type: 'ignoreIssue'; + args: { + id: string; + severity: 'Low' | 'Medium' | 'High'; + lineOnly: boolean; + message: string; + rule: string; + uri: string; + cols: [number, number]; + rows: [number, number]; + }; + }; + + type OpenBrowserMessage = { + type: 'openBrowser'; + args: { + url: string; + }; + }; + + type GetAutofixDiffsMesssage = { + type: 'getAutofixDiffs'; + args: { + suggestion: Suggestion; + }; + }; + + type ApplyGitDiffMessage = { + type: 'applyGitDiff'; + args: { + patch: string; + filePath: string; + }; + }; + + type SetSuggestionMessage = { + type: 'set'; + args: Suggestion; + }; + + type GetSuggestionMessage = { + type: 'get'; + }; + + type SetLessonMessage = { + type: 'setLesson'; + args: Lesson; + }; + + type GetLessonMessage = { + type: 'getLesson'; + }; + + type SetAutofixDiffsMessage = { + type: 'setAutofixDiffs'; + args: { + suggestion: Suggestion; + diffs: AutofixUnifiedDiffSuggestion[]; + }; + }; + + type SetAutofixErrorMessage = { + type: 'setAutofixError'; + args: { + suggestion: Suggestion; + }; + }; + + type SuggestionMessage = + | OpenLocalMessage + | OpenBrowserMessage + | IgnoreIssueMessage + | GetAutofixDiffsMesssage + | ApplyGitDiffMessage + | SetSuggestionMessage + | GetSuggestionMessage + | SetLessonMessage + | GetLessonMessage + | SetAutofixDiffsMessage + | SetAutofixErrorMessage; + const vscode = acquireVsCodeApi(); const elements = { - readMoreBtnElem: document.querySelector('.read-more-btn') as HTMLElement, suggestionDetailsElem: document.querySelector('#suggestion-details') as HTMLElement, suggestionDetailsContentElem: document.querySelector('.suggestion-details-content') as HTMLElement, metaElem: document.getElementById('meta') as HTMLElement, @@ -79,6 +177,29 @@ declare const acquireVsCodeApi: any; datasetElem: document.getElementById('dataset-number') as HTMLElement, infoTopElem: document.getElementById('info-top') as HTMLElement, + diffTopElem: document.getElementById('diff-top') as HTMLElement, + diffElem: document.getElementById('diff') as HTMLElement, + noDiffsElem: document.getElementById('info-no-diffs') as HTMLElement, + diffNumElem: document.getElementById('diff-number') as HTMLElement, + diffNum2Elem: document.getElementById('diff-number2') as HTMLElement, + nextDiffElem: document.getElementById('next-diff') as HTMLElement, + previousDiffElem: document.getElementById('previous-diff') as HTMLElement, + diffSelectedIndexElem: document.getElementById('diff-counter') as HTMLElement, + + applyFixButton: document.getElementById('apply-fix') as HTMLElement, + retryGenerateFixButton: document.getElementById('retry-generate-fix') as HTMLElement, + generateAIFixButton: document.getElementById('generate-ai-fix') as HTMLElement, + + fixAnalysisTabElem: document.getElementById('fix-analysis-tab') as HTMLElement, + fixAnalysisContentElem: document.getElementById('fix-analysis-content') as HTMLElement, + vulnOverviewTabElem: document.getElementById('vuln-overview-tab') as HTMLElement, + vulnOverviewContentElem: document.getElementById('vuln-overview-content') as HTMLElement, + + fixLoadingIndicatorElem: document.getElementById('fix-loading-indicator') as HTMLElement, + fixWrapperElem: document.getElementById('fix-wrapper') as HTMLElement, + fixSectionElem: document.getElementById('fixes-section') as HTMLElement, + fixErrorSectionElem: document.getElementById('fixes-error-section') as HTMLElement, + exampleTopElem: document.getElementById('example-top') as HTMLElement, exampleElem: document.getElementById('example') as HTMLElement, noExamplesElem: document.getElementById('info-no-examples') as HTMLElement, @@ -86,16 +207,16 @@ declare const acquireVsCodeApi: any; exNum2Elem: document.getElementById('example-number2') as HTMLElement, }; - let isReadMoreBtnEventBound = false; - function navigateToUrl(url: string) { - sendMessage({ + const message: OpenBrowserMessage = { type: 'openBrowser', args: { url }, - }); + }; + sendMessage(message); } let exampleCount = 0; + let diffSelectedIndex = 0; // Try to restore the previous state let lesson: Lesson | null = vscode.getState()?.lesson || null; @@ -107,26 +228,32 @@ declare const acquireVsCodeApi: any; if (!suggestion?.leadURL) return; navigateToUrl(suggestion.leadURL); } + function navigateToIssue(_e: any, range: any) { if (!suggestion) return; - sendMessage({ + const message: OpenLocalMessage = { type: 'openLocal', args: getSuggestionPosition(suggestion, range), - }); + }; + + sendMessage(message); } + function navigateToCurrentExample() { if (!suggestion?.exampleCommitFixes) return; const url = suggestion.exampleCommitFixes[exampleCount].commitURL; - sendMessage({ + const message: OpenBrowserMessage = { type: 'openBrowser', args: { url }, - }); + }; + sendMessage(message); } + function ignoreIssue(lineOnly: boolean) { if (!suggestion) return; - sendMessage({ + const message: IgnoreIssueMessage = { type: 'ignoreIssue', args: { ...getSuggestionPosition(suggestion), @@ -136,27 +263,149 @@ declare const acquireVsCodeApi: any; severity: suggestion.severity, lineOnly: lineOnly, }, - }); + }; + sendMessage(message); } + function getSuggestionPosition(suggestionParam: Suggestion, position?: { file: string; rows: any; cols: any }) { return { - uri: position?.file ?? suggestionParam.uri, + uri: position?.file ?? suggestionParam.filePath, rows: position ? position.rows : suggestionParam.rows, cols: position ? position.cols : suggestionParam.cols, - suggestionUri: suggestionParam.uri, + suggestionUri: suggestionParam.filePath, + }; + } + + function nextDiff() { + if (!suggestion || !suggestion.diffs || diffSelectedIndex >= suggestion.diffs.length - 1) return; + ++diffSelectedIndex; + showCurrentDiff(); + } + + function previousDiff() { + if (!suggestion || !suggestion.diffs || diffSelectedIndex <= 0) return; + --diffSelectedIndex; + showCurrentDiff(); + } + + function applyFix() { + if (!suggestion) return; + const diffSuggestion = suggestion.diffs[diffSelectedIndex]; + const filePath = suggestion.filePath; + const patch = diffSuggestion.unifiedDiffsPerFile[filePath]; + + const message: ApplyGitDiffMessage = { + type: 'applyGitDiff', + args: { filePath, patch }, + }; + sendMessage(message); + } + + function generateAIFix() { + if (!suggestion) { + return; + } + + toggleElement(generateAIFixButton, 'hide'); + toggleElement(fixLoadingIndicatorElem, 'show'); + const message: GetAutofixDiffsMesssage = { + type: 'getAutofixDiffs', + args: { suggestion }, }; + sendMessage(message); } + + function retryGenerateAIFix() { + console.log('retrying generate AI Fix'); + + toggleElement(fixWrapperElem, 'show'); + toggleElement(fixErrorSectionElem, 'hide'); + + generateAIFix(); + } + function previousExample() { if (!suggestion || !suggestion.exampleCommitFixes || exampleCount <= 0) return; --exampleCount; showCurrentExample(); } + function nextExample() { if (!suggestion || !suggestion.exampleCommitFixes || exampleCount >= suggestion.exampleCommitFixes.length - 1) return; ++exampleCount; showCurrentExample(); } + + function showCurrentDiff() { + if (!suggestion?.diffs?.length || diffSelectedIndex < 0 || diffSelectedIndex >= suggestion.diffs.length) return; + + const { diffTopElem, diffElem, noDiffsElem, diffNumElem, diffNum2Elem, diffSelectedIndexElem } = elements; + + toggleElement(noDiffsElem, 'hide'); + toggleElement(diffTopElem, 'show'); + toggleElement(diffElem, 'show'); + + diffNumElem.innerText = suggestion.diffs.length.toString(); + diffNum2Elem.innerText = suggestion.diffs.length.toString(); + + diffSelectedIndexElem.innerText = (diffSelectedIndex + 1).toString(); + + const diffSuggestion = suggestion.diffs[diffSelectedIndex]; + + const filePath = suggestion.filePath; + const patch = diffSuggestion.unifiedDiffsPerFile[filePath]; + + // clear all elements + while (diffElem.firstChild) { + diffElem.removeChild(diffElem.firstChild); + } + diffElem.appendChild(generateDiffHtml(patch)); + } + + function generateDiffHtml(patch: string): HTMLElement { + const codeLines = patch.split('\n'); + + // the first two lines are the file names + codeLines.shift(); + codeLines.shift(); + + const diffHtml = document.createElement('div'); + let blockDiv: HTMLElement | null = null; + + for (const line of codeLines) { + if (line.startsWith('@@ ')) { + blockDiv = document.createElement('div'); + blockDiv.className = 'example'; + + if (blockDiv) { + diffHtml.appendChild(blockDiv); + } + } else { + const lineDiv = document.createElement('div'); + lineDiv.className = 'code-line'; + + if (line.startsWith('+')) { + lineDiv.classList.add('added'); + } else if (line.startsWith('-')) { + lineDiv.classList.add('removed'); + } else { + lineDiv.classList.add('none'); + } + + const lineCode = document.createElement('code'); + // if line is empty, we need to fallback to ' ' + // to make sure it displays in the diff + lineCode.innerText = line.slice(1, line.length) || ' '; + + lineDiv.appendChild(lineCode); + blockDiv?.appendChild(lineDiv); + } + } + + return diffHtml; + } + function showCurrentExample() { if ( !suggestion?.exampleCommitFixes?.length || @@ -176,7 +425,7 @@ declare const acquireVsCodeApi: any; example.querySelectorAll('*').forEach(n => n.remove()); for (const l of suggestion.exampleCommitFixes[exampleCount].lines) { const line = document.createElement('div'); - line.className = `example-line ${l.lineChange}`; + line.className = `code-line ${l.lineChange}`; example.appendChild(line); const code = document.createElement('code'); code.innerHTML = l.line; @@ -184,6 +433,20 @@ declare const acquireVsCodeApi: any; } } + function toggleElement(element: Element | null, toggle: 'hide' | 'show') { + if (!element) { + return; + } + + if (toggle === 'show') { + element.classList.remove('hidden'); + } else if (toggle === 'hide') { + element.classList.add('hidden'); + } else { + console.error('Unexpected toggle value', toggle); + } + } + /** * Transforms a severity string from a `Suggestion` object into a `CurrentSeverity` object. * @@ -327,30 +590,31 @@ declare const acquireVsCodeApi: any; descriptionElem.innerHTML = suggestion.message; } - moreInfoElem.className = suggestion.leadURL ? 'clickable' : 'clickable hidden'; + toggleElement(moreInfoElem, suggestion.leadURL ? 'show' : 'hide'); - suggestionPosition2Elem.innerHTML = (Number(suggestion.rows[0]) + 1).toString(); + suggestionPosition2Elem.innerText = (Number(suggestion.rows[0]) + 1).toString(); infoTopElem.classList.add('font-light'); if (suggestion.repoDatasetSize) { - datasetElem.innerHTML = suggestion.repoDatasetSize.toString(); + datasetElem.innerText = suggestion.repoDatasetSize.toString(); } else { - infoTopElem.classList.add('hidden'); + toggleElement(infoTopElem, 'hide'); } if (suggestion?.exampleCommitFixes?.length) { - exampleTopElem.className = 'row between'; - exampleElem.className = ''; + toggleElement(exampleTopElem, 'show'); + exNumElem.innerText = suggestion.exampleCommitFixes.length.toString(); + exNum2Elem.innerText = suggestion.exampleCommitFixes.length.toString(); - exNumElem.innerHTML = suggestion.exampleCommitFixes.length.toString(); + toggleElement(exampleElem, 'show'); + toggleElement(noExamplesElem, 'hide'); - exNum2Elem.innerHTML = suggestion.exampleCommitFixes.length.toString(); - noExamplesElem.className = 'hidden'; showCurrentExample(); } else { - exampleTopElem.className = 'row between hidden'; - exampleElem.className = 'hidden'; + toggleElement(exampleTopElem, 'hide'); noExamplesElem.className = 'font-light'; + + toggleElement(exampleElem, 'hide'); } } @@ -379,7 +643,7 @@ declare const acquireVsCodeApi: any; if (suggestion.cwe) { suggestion.cwe.forEach(cwe => { const cweElement = document.createElement('a'); - cweElement.className = 'suggestion-meta suggestion-cwe'; + cweElement.className = 'suggestion-meta suggestion-cwe is-external'; cweElement.href = `https://cwe.mitre.org/data/definitions/${cwe.split('-')[1]}.html`; cweElement.textContent = cwe; metaElem.appendChild(cweElement); @@ -404,50 +668,67 @@ declare const acquireVsCodeApi: any; if (suggestion.priorityScore !== undefined) { const priorityScoreElement = document.createElement('span'); priorityScoreElement.className = 'suggestion-meta'; - priorityScoreElement.textContent = `Priority Score: ${suggestion.priorityScore}`; + priorityScoreElement.textContent = `Priority score: ${suggestion.priorityScore}`; metaElem.appendChild(priorityScoreElement); } - } - function showSuggestionDetails(suggestion: Suggestion) { - const { suggestionDetailsElem, readMoreBtnElem, suggestionDetailsContentElem } = elements; + const fixesSection = document.querySelector('.ai-fix'); + const communityFixesSection = document.querySelector('.sn-community-fixes'); - if (!suggestion || !suggestion.text || !suggestionDetailsElem || !readMoreBtnElem) { - readMoreBtnElem.classList.add('hidden'); - suggestionDetailsContentElem.classList.add('hidden'); - return; + if (!suggestion.hasAIFix) { + toggleElement(fixesSection, 'hide'); + toggleElement(communityFixesSection, 'show'); + } else { + toggleElement(fixesSection, 'show'); + toggleElement(communityFixesSection, 'hide'); } + } + + function showSuggestionDetails(suggestion: Suggestion) { + const { + suggestionDetailsElem, + fixAnalysisTabElem, + fixAnalysisContentElem, + vulnOverviewTabElem, + vulnOverviewContentElem, + } = elements; suggestionDetailsElem.innerHTML = suggestion.text; - suggestionDetailsElem.classList.add('collapsed'); - readMoreBtnElem.classList.remove('hidden'); - suggestionDetailsContentElem.classList.remove('hidden'); - if (!isReadMoreBtnEventBound) { - readMoreBtnElem.addEventListener('click', () => { - const isCollapsed = suggestionDetailsElem.classList.contains('collapsed'); + fixAnalysisTabElem.addEventListener('click', () => { + fixAnalysisTabElem.classList.add('is-selected'); + fixAnalysisContentElem.classList.add('is-selected'); + vulnOverviewTabElem.classList.remove('is-selected'); + vulnOverviewContentElem.classList.remove('is-selected'); + }); - if (isCollapsed) { - suggestionDetailsElem.classList.remove('collapsed'); - readMoreBtnElem.textContent = 'Read less'; - } else { - suggestionDetailsElem.classList.add('collapsed'); - readMoreBtnElem.textContent = 'Read more'; - } - }); - isReadMoreBtnEventBound = true; - } + vulnOverviewTabElem.addEventListener('click', () => { + vulnOverviewContentElem.classList.add('is-selected'); + vulnOverviewTabElem.classList.add('is-selected'); + fixAnalysisTabElem.classList.remove('is-selected'); + fixAnalysisContentElem.classList.remove('is-selected'); + }); } - function sendMessage(message: { - type: string; - args: - | { uri: any; rows: any; cols: any } - | { url: any } - | { url: string } - | { message: any; rule: any; id: any; severity: any; lineOnly: boolean; uri: any; rows: any; cols: any } - | { suggestion: any }; - }) { + const { + generateAIFixButton, + retryGenerateFixButton, + applyFixButton, + nextDiffElem, + previousDiffElem, + fixSectionElem, + fixLoadingIndicatorElem, + fixWrapperElem, + fixErrorSectionElem, + } = elements; + + generateAIFixButton?.addEventListener('click', generateAIFix); + retryGenerateFixButton?.addEventListener('click', retryGenerateAIFix); + nextDiffElem.addEventListener('click', nextDiff); + previousDiffElem.addEventListener('click', previousDiff); + applyFixButton.addEventListener('click', applyFix); + + function sendMessage(message: SuggestionMessage) { vscode.postMessage(message); } @@ -464,21 +745,24 @@ declare const acquireVsCodeApi: any; // deepcode ignore InsufficientValidation: Content Security Policy applied in provider window.addEventListener('message', event => { - const { type, args } = event.data; - switch (type) { + const message = event.data as SuggestionMessage; + switch (message.type) { case 'set': { - suggestion = args; + suggestion = message.args; vscode.setState({ ...vscode.getState(), suggestion }); showCurrentSuggestion(); break; } case 'get': { - suggestion = vscode.getState()?.suggestion || {}; - showCurrentSuggestion(); + const newSuggestion = vscode.getState()?.suggestion || {}; + if (newSuggestion != suggestion) { + suggestion = newSuggestion; + showCurrentSuggestion(); + } break; } case 'setLesson': { - lesson = args; + lesson = message.args; vscode.setState({ ...vscode.getState(), lesson }); fillLearnLink(); break; @@ -488,6 +772,30 @@ declare const acquireVsCodeApi: any; fillLearnLink(); break; } + case 'setAutofixDiffs': { + if (suggestion?.id === message.args.suggestion.id) { + toggleElement(fixSectionElem, 'show'); + toggleElement(fixLoadingIndicatorElem, 'hide'); + toggleElement(fixWrapperElem, 'hide'); + + const { diffs } = message.args; + suggestion.diffs = diffs; + + vscode.setState({ ...vscode.getState(), suggestion }); + showCurrentDiff(); + } + break; + } + case 'setAutofixError': { + const errorSuggestion = message.args.suggestion; + + if (errorSuggestion.id != suggestion?.id) { + console.log('Got an error for a previously generated suggestion: ignoring'); + break; + } + toggleElement(fixWrapperElem, 'hide'); + toggleElement(fixErrorSectionElem, 'show'); + } } }); })(); diff --git a/src/snyk/snykCode/views/suggestion/types.ts b/src/snyk/snykCode/views/suggestion/types.ts new file mode 100644 index 000000000..3a6513b7f --- /dev/null +++ b/src/snyk/snykCode/views/suggestion/types.ts @@ -0,0 +1,113 @@ +import { AutofixUnifiedDiffSuggestion, ExampleCommitFix, Marker, Point } from '../../../common/languageServer/types'; +import { Lesson } from '../../../common/services/learnService'; + +export type Suggestion = { + id: string; + message: string; + severity: string; + leadURL?: string; + rule: string; + repoDatasetSize: number; + exampleCommitFixes: ExampleCommitFix[]; + cwe: string[]; + title: string; + text: string; + isSecurityType: boolean; + markers?: Marker[]; + cols: Point; + rows: Point; + hasAIFix?: boolean; + filePath: string; +}; + +export type OpenLocalMessage = { + type: 'openLocal'; + args: { + uri: string; + cols: [number, number]; + rows: [number, number]; + suggestionUri: string; + }; +}; + +export type IgnoreIssueMessage = { + type: 'ignoreIssue'; + args: { + id: string; + severity: 'Low' | 'Medium' | 'High'; + lineOnly: boolean; + message: string; + rule: string; + uri: string; + cols: [number, number]; + rows: [number, number]; + }; +}; + +export type OpenBrowserMessage = { + type: 'openBrowser'; + args: { + url: string; + }; +}; + +export type GetAutofixDiffsMesssage = { + type: 'getAutofixDiffs'; + args: { + suggestion: Suggestion; + }; +}; + +export type ApplyGitDiffMessage = { + type: 'applyGitDiff'; + args: { + patch: string; + filePath: string; + }; +}; + +export type SetSuggestionMessage = { + type: 'set'; + args: Suggestion; +}; + +export type GetSuggestionMessage = { + type: 'get'; +}; + +export type SetLessonMessage = { + type: 'setLesson'; + args: Lesson | null; +}; + +export type GetLessonMessage = { + type: 'getLesson'; +}; + +export type SetAutofixDiffsMessage = { + type: 'setAutofixDiffs'; + args: { + suggestion: Suggestion; + diffs: AutofixUnifiedDiffSuggestion[]; + }; +}; + +export type SetAutofixErrorMessage = { + type: 'setAutofixError'; + args: { + suggestion: Suggestion; + }; +}; + +export type SuggestionMessage = + | OpenLocalMessage + | OpenBrowserMessage + | IgnoreIssueMessage + | GetAutofixDiffsMesssage + | ApplyGitDiffMessage + | SetSuggestionMessage + | GetSuggestionMessage + | SetLessonMessage + | GetLessonMessage + | SetAutofixDiffsMessage + | SetAutofixErrorMessage; diff --git a/src/snyk/snykCode/views/webviewPanelSerializer.ts b/src/snyk/snykCode/views/webviewPanelSerializer.ts new file mode 100644 index 000000000..e48327bef --- /dev/null +++ b/src/snyk/snykCode/views/webviewPanelSerializer.ts @@ -0,0 +1,14 @@ +import * as vscode from 'vscode'; +import { WebviewProvider } from '../../../snyk/common/views/webviewProvider'; +import { Logger } from '../../common/logger/logger'; + +export class WebviewPanelSerializer, State> + implements vscode.WebviewPanelSerializer +{ + constructor(private readonly provider: Provider) {} + async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel): Promise { + // we want to make sure the panel is closed on startup + webviewPanel.dispose(); + return Promise.resolve(); + } +} diff --git a/src/snyk/snykOss/providers/ossVulnerabilityTreeProvider.ts b/src/snyk/snykOss/providers/ossVulnerabilityTreeProvider.ts index f3aeba284..1aadf6c2d 100644 --- a/src/snyk/snykOss/providers/ossVulnerabilityTreeProvider.ts +++ b/src/snyk/snykOss/providers/ossVulnerabilityTreeProvider.ts @@ -38,9 +38,8 @@ export default class OssIssueTreeProvider extends ProductIssueTreeProvider) => { fileSeverityCounts[issue.severity] += 1; - totalVulnCount++; folderVulnCount++; return new TreeNode({ @@ -140,7 +138,7 @@ export default class OssIssueTreeProvider extends ProductIssueTreeProvider { rows: [1, 2], isSecurityType: true, priorityScore: 880, + hasAIFix: false, }, title: 'not used', severity: IssueSeverity.Critical, diff --git a/src/test/unit/snykCode/utils/patchUtils.test.ts b/src/test/unit/snykCode/utils/patchUtils.test.ts new file mode 100644 index 000000000..96e5ba63e --- /dev/null +++ b/src/test/unit/snykCode/utils/patchUtils.test.ts @@ -0,0 +1,118 @@ +import assert from 'assert'; +import * as patchUtils from '../../../../snyk/snykCode/utils/patchUtils'; +import { IVSCodeLanguages } from '../../../../snyk/common/vscode/languages'; + +suite('generateDecorationOptions', () => { + let languages: IVSCodeLanguages; + + setup(() => { + languages = { + createRange: (startLine: number, startCharacter: number, endLine: number, endCharacter: number) => ({ + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + }), + } as IVSCodeLanguages; + }); + + test('generates ranges for adding to empty files', () => { + const patch = `--- /home/mike/boom ++++ /home/mike/boom-fixed +@@ -1 1,4 @@ ++ def main(): ++ print("hello world") ++ ++ main()`; + const result = patchUtils.generateDecorationOptions(patch, languages); + assert.strictEqual(result.length, 4); + + assert.strictEqual(result[0].range.start.line, 0); + assert.strictEqual(result[0].range.start.character, 0); + assert.strictEqual(result[0].range.end.line, 0); + assert.strictEqual(result[0].range.end.character, 12); + + assert.strictEqual(result[3].range.start.line, 3); + assert.strictEqual(result[3].range.start.character, 0); + assert.strictEqual(result[3].range.end.line, 3); + assert.strictEqual(result[3].range.end.character, 7); + }); + + test('generates empty result for completely removing a file', () => { + const patch = `--- /home/mike/boom ++++ /home/mike/boom-fixed +@@ -1,4 1 @@ +- def main(): +- print("hello world") +- +- main()`; + const result = patchUtils.generateDecorationOptions(patch, languages); + assert.strictEqual(result.length, 0); + }); + + test('works with single hunks', () => { + const patch = `-- /home/patch/goof ++++ /home/patch/goof-fixed +@@ -1 +15,8 @@ + var fileType = require('file-type'); + var AdmZip = require('adm-zip'); + var fs = require('fs'); ++var RateLimit = require('express-rate-limit'); ++var limiter = new RateLimit({ ++ windowMs: parseInt(process.env.WINDOW_MS, 10), ++ max: parseInt(process.env.MAX_IP_REQUESTS, 10), ++ delayMs:parseInt(process.env.DELAY_MS, 10), ++ headers: true ++}); ++app.user(limiter); + + // prototype-pollution + var _ = require('lodash');`; + + const result = patchUtils.generateDecorationOptions(patch, languages); + + assert.strictEqual(result.length, 8); + + assert.strictEqual(result[0].range.start.line, 17); + assert.strictEqual(result[0].range.start.character, 0); + assert.strictEqual(result[0].range.end.line, 17); + assert.strictEqual(result[0].range.end.character, 46); + + assert.strictEqual(result[7].range.start.line, 24); + assert.strictEqual(result[7].range.start.character, 0); + assert.strictEqual(result[7].range.end.line, 24); + assert.strictEqual(result[7].range.end.character, 18); + }); + + test('works with multiple hunks', () => { + const patch = `-- /home/patch/snek ++++ /home/patch/snek-fixed +@@ -1,2 +1,2 @@ + import math + from my_module import do_some_work + + def generate_number() -> int: +- return math.random() * 100 ++ return math.random() * 20 + + result = do_some_work() + print(result) + +@@ -25,1 +25,1 @@ +-result *= generate_number() ++result += generate_number() +`; + + const result = patchUtils.generateDecorationOptions(patch, languages); + + assert.strictEqual(result.length, 2); + + assert.strictEqual(result[0].range.start.line, 4); + assert.strictEqual(result[0].range.start.character, 0); + assert.strictEqual(result[0].range.end.line, 4); + assert.strictEqual(result[0].range.end.character, 28); + + assert.strictEqual(result[1].range.start.line, 24); + assert.strictEqual(result[1].range.start.character, 0); + assert.strictEqual(result[1].range.end.line, 24); + assert.strictEqual(result[1].range.end.character, 27); + }); +});