diff --git a/src/codewhisperer/images/severity-critical.svg b/src/codewhisperer/images/severity-critical.svg new file mode 100644 index 00000000000..3808e181487 --- /dev/null +++ b/src/codewhisperer/images/severity-critical.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 49 20" width="39" height="16" role="img" aria-label="Critical"><title>Critical</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="49" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="0" height="20" fill="#7d2105"/><rect x="0" width="49" height="20" fill="#7d2105"/><rect width="49" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="245" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="390">Critical</text><text x="245" y="140" transform="scale(.1)" fill="#fff" textLength="390">Critical</text></g></svg> diff --git a/src/codewhisperer/images/severity-high.svg b/src/codewhisperer/images/severity-high.svg new file mode 100644 index 00000000000..648e951c5f4 --- /dev/null +++ b/src/codewhisperer/images/severity-high.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 35 20" width="28" height="16" role="img" aria-label="High"><title>High</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="35" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="0" height="20" fill="#ba2e0f"/><rect x="0" width="35" height="20" fill="#ba2e0f"/><rect width="35" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="175" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="250">High</text><text x="175" y="140" transform="scale(.1)" fill="#fff" textLength="250">High</text></g></svg> diff --git a/src/codewhisperer/images/severity-info.svg b/src/codewhisperer/images/severity-info.svg new file mode 100644 index 00000000000..2bb7760f268 --- /dev/null +++ b/src/codewhisperer/images/severity-info.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 33 20" width="26" height="16" role="img" aria-label="Info"><title>Info</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="33" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="0" height="20" fill="#879596"/><rect x="0" width="33" height="20" fill="#879596"/><rect width="33" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="165" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="230">Info</text><text x="165" y="140" transform="scale(.1)" fill="#fff" textLength="230">Info</text></g></svg> diff --git a/src/codewhisperer/images/severity-low.svg b/src/codewhisperer/images/severity-low.svg new file mode 100644 index 00000000000..e14d1d39a92 --- /dev/null +++ b/src/codewhisperer/images/severity-low.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 31 20" width="25" height="16" role="img" aria-label="Low"><title>Low</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="31" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="0" height="20" fill="#b2911c"/><rect x="0" width="31" height="20" fill="#b2911c"/><rect width="31" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="155" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="210">Low</text><text x="155" y="140" transform="scale(.1)" fill="#fff" textLength="210">Low</text></g></svg> diff --git a/src/codewhisperer/images/severity-medium.svg b/src/codewhisperer/images/severity-medium.svg new file mode 100644 index 00000000000..121a1c0983c --- /dev/null +++ b/src/codewhisperer/images/severity-medium.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 53 20" width="42" height="16" role="img" aria-label="Medium"><title>Medium</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="53" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="0" height="20" fill="#cc5f21"/><rect x="0" width="53" height="20" fill="#cc5f21"/><rect width="53" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="265" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">Medium</text><text x="265" y="140" transform="scale(.1)" fill="#fff" textLength="430">Medium</text></g></svg> diff --git a/src/codewhisperer/models/model.ts b/src/codewhisperer/models/model.ts index a4832c36e84..578a7eb6b55 100644 --- a/src/codewhisperer/models/model.ts +++ b/src/codewhisperer/models/model.ts @@ -168,21 +168,35 @@ export interface CodeScanTelemetryEntry { credentialStartUrl: string | undefined } +export interface RecommendationDescription { + text: string + markdown: string +} + +export interface SuggestedFix { + description: string + code: string +} + export interface RawCodeScanIssue { filePath: string startLine: number endLine: number title: string - description: { - text: string - markdown: string - } + description: RecommendationDescription } export interface CodeScanIssue { startLine: number endLine: number comment: string + title: string + description: RecommendationDescription + detectorId: string + detectorName: string + relatedVulnerabilities: string[] + severity: string + suggestedFixes?: SuggestedFix[] } export interface AggregatedCodeScanIssue { diff --git a/src/codewhisperer/service/diagnosticsProvider.ts b/src/codewhisperer/service/diagnosticsProvider.ts index 2d4e8ebbe9e..8cc73224db2 100644 --- a/src/codewhisperer/service/diagnosticsProvider.ts +++ b/src/codewhisperer/service/diagnosticsProvider.ts @@ -45,7 +45,7 @@ export function createSecurityDiagnostic(securityIssue: CodeScanIssue) { const range = new vscode.Range(securityIssue.startLine, 0, securityIssue.endLine, 0) const securityDiagnostic: vscode.Diagnostic = new vscode.Diagnostic( range, - securityIssue.comment, + securityIssue.title, vscode.DiagnosticSeverity.Warning ) securityDiagnostic.source = 'Detected by CodeWhisperer ' diff --git a/src/codewhisperer/service/securityIssueHoverProvider.ts b/src/codewhisperer/service/securityIssueHoverProvider.ts index 0145a828351..4454a07968f 100644 --- a/src/codewhisperer/service/securityIssueHoverProvider.ts +++ b/src/codewhisperer/service/securityIssueHoverProvider.ts @@ -4,6 +4,7 @@ */ import * as vscode from 'vscode' import { AggregatedCodeScanIssue, CodeScanIssue } from '../models/model' +import globals from '../../shared/extensionGlobals' export class SecurityIssueHoverProvider implements vscode.HoverProvider { static #instance: SecurityIssueHoverProvider @@ -41,6 +42,94 @@ export class SecurityIssueHoverProvider implements vscode.HoverProvider { } private _getContent(issue: CodeScanIssue) { - return new vscode.MarkdownString('TBD') + const markdownString = new vscode.MarkdownString() + markdownString.isTrusted = true + markdownString.supportHtml = true + markdownString.supportThemeIcons = true + + const [suggestedFix] = issue.suggestedFixes ?? [] + + if (suggestedFix) { + markdownString.appendMarkdown( + `## Suggested Fix for ${issue.title} ${this._makeSeverityBadge(issue.severity)}\n` + ) + } else { + markdownString.appendMarkdown(`## ${issue.title} ${this._makeSeverityBadge(issue.severity)}\n`) + } + + markdownString.appendMarkdown(`${issue.description.markdown}\n\n`) + + const viewDetailsCommand = vscode.Uri.parse('command:aws.codewhisperer.viewSecurityIssue') + const applyFixCommand = vscode.Uri.parse('command:aws.codewhisperer.applySecurityFix') + markdownString.appendMarkdown(`[$(eye) View Details](${viewDetailsCommand})\n`) + + if (suggestedFix) { + markdownString.appendMarkdown(` | [$(wrench) Apply Fix](${applyFixCommand})\n`) + markdownString.appendMarkdown( + `${this._makeCodeBlock(suggestedFix.code, issue.detectorId.split('/').shift())}\n` + ) + } + + return markdownString + } + + private _makeSeverityBadge(severity: string) { + if (!severity) { + return '' + } + return `![${severity}](${vscode.Uri.joinPath( + globals.context.extensionUri, + `src/codewhisperer/images/severity-${severity.toLowerCase()}.svg` + )})` + } + + /** + * Creates a markdown string to render a code diff block for a given code block. Lines + * that are highlighted red indicate deletion while lines highlighted in green indicate + * addition. An optional language can be provided for syntax highlighting on lines which are + * not additions or deletions. + * + * @param code The code containing the diff + * @param language The language for syntax highlighting + * @returns The markdown string + */ + private _makeCodeBlock(code: string, language?: string) { + // Add some padding so that each line has the same number of chars + const lines = code.split('\n').slice(1) // Ignore the first line for diff header + const maxLineChars = lines.reduce((acc, curr) => Math.max(acc, curr.length), 0) + const paddedLines = lines.map(line => line.padEnd(maxLineChars + 2)) + + // Group the lines into sections so consecutive lines of the same type can be placed in + // the same span below + const sections = [paddedLines[0]] + let i = 1 + while (i < paddedLines.length) { + if (paddedLines[i][0] === sections[sections.length - 1][0]) { + sections[sections.length - 1] += '\n' + paddedLines[i] + } else { + sections.push(paddedLines[i]) + } + i++ + } + + // Return each section with the correct syntax highlighting and background color + return sections + .map( + section => ` +<span class="codicon codicon-none" style="background-color:var(${ + section.startsWith('-') + ? '--vscode-diffEditor-removedTextBackground' + : section.startsWith('+') + ? '--vscode-diffEditor-insertedTextBackground' + : '--vscode-textCodeBlock-background' + });"> + +\`\`\`${section.startsWith('-') || section.startsWith('+') ? 'diff' : language} +${section} +\`\`\` + +</span>` + ) + .join('') } } diff --git a/src/codewhisperer/service/securityScanHandler.ts b/src/codewhisperer/service/securityScanHandler.ts index 4a32893b951..e1198a93fc5 100644 --- a/src/codewhisperer/service/securityScanHandler.ts +++ b/src/codewhisperer/service/securityScanHandler.ts @@ -81,6 +81,14 @@ function mapToAggregatedList( : issue.endLine, endLine: issue.endLine, comment: `${issue.title.trim()}: ${issue.description.text.trim()}`, + title: issue.title, + description: issue.description, + // TODO: Update this to use the actual values + detectorId: '', + detectorName: '', + relatedVulnerabilities: [], + severity: '', + suggestedFixes: [], } }), } diff --git a/src/test/codewhisperer/service/securityIssueHoverProvider.test.ts b/src/test/codewhisperer/service/securityIssueHoverProvider.test.ts index 0744d1fd7d7..5f4968da88f 100644 --- a/src/test/codewhisperer/service/securityIssueHoverProvider.test.ts +++ b/src/test/codewhisperer/service/securityIssueHoverProvider.test.ts @@ -7,18 +7,41 @@ import * as vscode from 'vscode' import { SecurityIssueHoverProvider } from '../../../codewhisperer/service/securityIssueHoverProvider' import { createMockDocument } from '../testUtil' import assert from 'assert' +import { CodeScanIssue } from '../../../codewhisperer/models/model' +import sinon from 'sinon' + +const makeIssue = (overrides?: Partial<CodeScanIssue>): CodeScanIssue => ({ + startLine: 0, + endLine: 0, + comment: 'comment', + title: 'title', + description: { + text: 'description', + markdown: 'description', + }, + detectorId: 'language/cool-detector@v1.0', + detectorName: 'detectorName', + relatedVulnerabilities: [], + severity: 'High', + suggestedFixes: [ + { description: 'fix', code: '@@ -1,1 +1,1 @@\nfirst line\n-second line\n+third line\nfourth line' }, + ], + ...overrides, +}) describe('securityIssueHoverProvider', () => { describe('providerHover', () => { it('should return hover for each issue for the current position', () => { + sinon.stub(vscode.Uri, 'joinPath').callsFake(() => vscode.Uri.parse('myPath')) + const securityIssueHoverProvider = new SecurityIssueHoverProvider() const mockDocument = createMockDocument('def two_sum(nums, target):\nfor', 'test.py', 'python') securityIssueHoverProvider.issues = [ { filePath: mockDocument.fileName, issues: [ - { startLine: 0, endLine: 1, comment: 'issue on this line' }, - { startLine: 0, endLine: 1, comment: 'some other issue' }, + makeIssue({ startLine: 0, endLine: 1 }), + makeIssue({ startLine: 0, endLine: 1, suggestedFixes: [] }), ], }, ] @@ -27,8 +50,39 @@ describe('securityIssueHoverProvider', () => { const actual = securityIssueHoverProvider.provideHover(mockDocument, new vscode.Position(0, 0), token.token) assert.strictEqual(actual.contents.length, 2) - assert.strictEqual((actual.contents[0] as vscode.MarkdownString).value, 'TBD') - assert.strictEqual((actual.contents[1] as vscode.MarkdownString).value, 'TBD') + assert.strictEqual( + (actual.contents[0] as vscode.MarkdownString).value, + '## Suggested Fix for title ![High](file:///myPath)\n' + + 'description\n\n' + + '[$(eye) View Details](command:aws.codewhisperer.viewSecurityIssue)\n' + + ' | [$(wrench) Apply Fix](command:aws.codewhisperer.applySecurityFix)\n\n' + + '<span class="codicon codicon-none" style="background-color:var(--vscode-textCodeBlock-background);">\n\n' + + '```language\n' + + 'first line \n' + + '```\n\n' + + '</span>\n' + + '<span class="codicon codicon-none" style="background-color:var(--vscode-diffEditor-removedTextBackground);">\n\n' + + '```diff\n' + + '-second line \n' + + '```\n\n' + + '</span>\n' + + '<span class="codicon codicon-none" style="background-color:var(--vscode-diffEditor-insertedTextBackground);">\n\n' + + '```diff\n' + + '+third line \n' + + '```\n\n' + + '</span>\n' + + '<span class="codicon codicon-none" style="background-color:var(--vscode-textCodeBlock-background);">\n\n' + + '```language\n' + + 'fourth line \n' + + '```\n\n' + + '</span>\n' + ) + assert.strictEqual( + (actual.contents[1] as vscode.MarkdownString).value, + '## title ![High](file:///myPath)\n' + + 'description\n\n' + + '[$(eye) View Details](command:aws.codewhisperer.viewSecurityIssue)\n' + ) }) it('should return empty contents if there is no issue on the current position', () => { @@ -37,7 +91,7 @@ describe('securityIssueHoverProvider', () => { securityIssueHoverProvider.issues = [ { filePath: mockDocument.fileName, - issues: [{ startLine: 0, endLine: 1, comment: 'issue on this line' }], + issues: [makeIssue({ startLine: 0, endLine: 1 })], }, ] diff --git a/src/test/codewhisperer/views/securityPanelViewProvider.test.ts b/src/test/codewhisperer/views/securityPanelViewProvider.test.ts index a7b51ab113e..96f69dcbc6e 100644 --- a/src/test/codewhisperer/views/securityPanelViewProvider.test.ts +++ b/src/test/codewhisperer/views/securityPanelViewProvider.test.ts @@ -17,6 +17,16 @@ const codeScanIssue: CodeScanIssue[] = [ startLine: 0, endLine: 4, comment: 'foo', + title: 'bar', + description: { + text: 'foo', + markdown: 'foo', + }, + detectorId: '', + detectorName: '', + relatedVulnerabilities: [], + severity: 'low', + suggestedFixes: [], }, ]