| /*--------------------------------------------------------- |
| * Copyright 2022 The Go Authors. All rights reserved. |
| * Licensed under the MIT License. See LICENSE in the project root for license information. |
| *--------------------------------------------------------*/ |
| |
| import path from 'path'; |
| import fs from 'fs'; |
| import * as vscode from 'vscode'; |
| import { GoExtensionContext } from './context'; |
| import { getBinPath } from './util'; |
| import * as cp from 'child_process'; |
| import { toolExecutionEnvironment } from './goEnv'; |
| import { killProcessTree } from './utils/processUtils'; |
| import * as readline from 'readline'; |
| import { URI } from 'vscode-uri'; |
| import { promisify } from 'util'; |
| |
| export class VulncheckResultViewProvider implements vscode.CustomTextEditorProvider { |
| public static readonly viewType = 'vulncheck.view'; |
| |
| public static register({ extensionUri, subscriptions }: vscode.ExtensionContext): VulncheckResultViewProvider { |
| const provider = new VulncheckResultViewProvider(extensionUri); |
| subscriptions.push(vscode.window.registerCustomEditorProvider(VulncheckResultViewProvider.viewType, provider)); |
| return provider; |
| } |
| |
| constructor(private readonly extensionUri: vscode.Uri) {} |
| |
| /** |
| * Called when our custom editor is opened. |
| */ |
| public async resolveCustomTextEditor( |
| document: vscode.TextDocument, |
| webviewPanel: vscode.WebviewPanel, |
| _: vscode.CancellationToken // eslint-disable-line @typescript-eslint/no-unused-vars |
| ): Promise<void> { |
| // Setup initial content for the webview |
| webviewPanel.webview.options = { enableScripts: true }; |
| webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview); |
| |
| // Receive message from the webview. |
| webviewPanel.webview.onDidReceiveMessage(this.handleMessage); |
| |
| function updateWebview() { |
| webviewPanel.webview.postMessage({ type: 'update', text: document.getText() }); |
| } |
| |
| // Hook up event handlers so that we can synchronize the webview with the text document. |
| // |
| // The text document acts as our model, so we have to sync change in the document to our |
| // editor and sync changes in the editor back to the document. |
| // |
| // Remember that a single text document can also be shared between multiple custom |
| // editors (this happens for example when you split a custom editor) |
| const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => { |
| if (e.document.uri.toString() === document.uri.toString()) { |
| updateWebview(); |
| } |
| }); |
| |
| // Make sure we get rid of the listener when our editor is closed. |
| webviewPanel.onDidDispose(() => { |
| changeDocumentSubscription.dispose(); |
| }); |
| |
| updateWebview(); |
| } |
| |
| /** |
| * Get the static html used for the editor webviews. |
| */ |
| private getHtmlForWebview(webview: vscode.Webview): string { |
| const mediaUri = vscode.Uri.joinPath(this.extensionUri, 'media'); |
| // Local path to script and css for the webview |
| const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaUri, 'vulncheckView.js')); |
| const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaUri, 'reset.css')); |
| const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaUri, 'vscode.css')); |
| const styleMainUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaUri, 'vulncheckView.css')); |
| |
| // Use a nonce to whitelist which scripts can be run |
| const nonce = getNonce(); |
| |
| return /* html */ ` |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <!-- |
| Use a content security policy to only allow loading images from https or from our extension directory, |
| and only allow scripts that have a specific nonce. |
| --> |
| <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <link href="${styleResetUri}" rel="stylesheet" /> |
| <link href="${styleVSCodeUri}" rel="stylesheet" /> |
| <link href="${styleMainUri}" rel="stylesheet" /> |
| <title>Vulnerability Report - govulncheck</title> |
| </head> |
| <body> |
| <div class="log"></div> |
| <div class="vulns"></div> |
| |
| <script nonce="${nonce}" src="${scriptUri}"></script> |
| </body> |
| </html>`; |
| } |
| |
| private handleMessage(e: { type: string; target?: string }): void { |
| switch (e.type) { |
| case 'open': |
| { |
| if (!e.target) return; |
| const uri = safeURIParse(e.target); |
| if (!uri || !uri.scheme) return; |
| if (uri.scheme === 'https') { |
| vscode.env.openExternal(uri); |
| } else if (uri.scheme === 'file') { |
| const line = uri.query ? Number(uri.query.split(':')[0]) : undefined; |
| const range = line ? new vscode.Range(line, 0, line, 0) : undefined; |
| vscode.window.showTextDocument( |
| vscode.Uri.from({ scheme: uri.scheme, path: uri.path }), |
| // prefer the first column to present the source. |
| { viewColumn: vscode.ViewColumn.One, selection: range } |
| ); |
| } |
| } |
| return; |
| case 'snapshot-result': |
| // response for `snapshot-request`. |
| return; |
| default: |
| console.log(`unrecognized type message: ${e.type}`); |
| } |
| } |
| } |
| |
| export class VulncheckProvider { |
| static scheme = 'govulncheck'; |
| static setup({ subscriptions }: vscode.ExtensionContext, goCtx: GoExtensionContext) { |
| const channel = vscode.window.createOutputChannel('govulncheck'); |
| const instance = new this(channel); |
| subscriptions.push( |
| vscode.commands.registerCommand('go.vulncheck.run', async () => { |
| instance.run(goCtx); |
| }) |
| ); |
| return instance; |
| } |
| |
| constructor(private channel: vscode.OutputChannel) {} |
| |
| private running = false; |
| |
| async run(goCtx: GoExtensionContext) { |
| if (this.running) { |
| vscode.window.showWarningMessage('another vulncheck is in progress'); |
| return; |
| } |
| try { |
| this.running = true; |
| await this.runInternal(goCtx); |
| } finally { |
| this.running = false; |
| } |
| } |
| |
| private async runInternal(goCtx: GoExtensionContext) { |
| const pick = await vscode.window.showQuickPick(['Current Package', 'Workspace']); |
| let dir, pattern: string; |
| const document = vscode.window.activeTextEditor?.document; |
| switch (pick) { |
| case 'Current Package': |
| if (!document) { |
| vscode.window.showErrorMessage('vulncheck error: no current package'); |
| return; |
| } |
| if (document.languageId !== 'go') { |
| vscode.window.showErrorMessage( |
| 'File in the active editor is not a Go file, cannot find current package to check.' |
| ); |
| return; |
| } |
| dir = path.dirname(document.fileName); |
| pattern = '.'; |
| break; |
| case 'Workspace': |
| dir = await this.activeDir(); |
| pattern = './...'; |
| break; |
| default: |
| return; |
| } |
| if (!dir) { |
| return; |
| } |
| |
| this.channel.clear(); |
| this.channel.appendLine(`cd ${dir}; gopls vulncheck ${pattern}`); |
| |
| try { |
| const start = new Date(); |
| const vuln = await vulncheck(goCtx, dir, pattern, this.channel); |
| |
| if (vuln) { |
| fillAffectedPkgs(vuln.Vuln); |
| |
| // record run info. |
| vuln.Start = start; |
| vuln.Duration = Date.now() - start.getTime(); |
| vuln.Dir = dir; |
| vuln.Pattern = pattern; |
| |
| // write to file and visualize it! |
| const fname = path.join(dir, `vulncheck-${Date.now()}.vulncheck.json`); |
| const writeFile = promisify(fs.writeFile); |
| await writeFile(fname, JSON.stringify(vuln)); |
| const uri = URI.file(fname); |
| const viewColumn = vscode.ViewColumn.Beside; |
| vscode.commands.executeCommand( |
| 'vscode.openWith', |
| uri, |
| VulncheckResultViewProvider.viewType, |
| viewColumn |
| ); |
| this.channel.appendLine(`Vulncheck - result wrote in ${fname}`); |
| } else { |
| this.channel.appendLine('Vulncheck - found no vulnerability'); |
| } |
| } catch (e) { |
| vscode.window.showErrorMessage(`error running vulncheck: ${e}`); |
| this.channel.appendLine(`Vulncheck failed: ${e}`); |
| } |
| this.channel.show(); |
| } |
| |
| private async activeDir() { |
| const folders = vscode.workspace.workspaceFolders; |
| if (!folders || folders.length === 0) return; |
| let dir: string | undefined = ''; |
| if (folders.length === 1) { |
| dir = folders[0].uri.path; |
| } else { |
| const pick = await vscode.window.showQuickPick( |
| folders.map((f) => ({ label: f.name, description: f.uri.path })) |
| ); |
| dir = pick?.description; |
| } |
| return dir; |
| } |
| } |
| |
| // run `gopls vulncheck`. |
| export async function vulncheck( |
| goCtx: GoExtensionContext, |
| dir: string, |
| pattern = './...', |
| channel: { appendLine: (msg: string) => void } |
| ): Promise<VulncheckReport> { |
| const { languageClient, serverInfo } = goCtx; |
| const COMMAND = 'gopls.run_vulncheck_exp'; |
| if (!languageClient || !serverInfo?.Commands?.includes(COMMAND)) { |
| throw Promise.reject('this feature requires gopls v0.8.4 or newer'); |
| } |
| // TODO: read back the actual package configuration from gopls. |
| const gopls = getBinPath('gopls'); |
| const options: vscode.ProgressOptions = { |
| cancellable: true, |
| title: 'Run govulncheck', |
| location: vscode.ProgressLocation.Notification |
| }; |
| const task = vscode.window.withProgress<VulncheckReport>(options, (progress, token) => { |
| const p = cp.spawn(gopls, ['vulncheck', pattern], { |
| cwd: dir, |
| env: toolExecutionEnvironment(vscode.Uri.file(dir)) |
| }); |
| |
| progress.report({ message: `starting command ${gopls} from ${dir} (pid; ${p.pid})` }); |
| |
| const d = token.onCancellationRequested(() => { |
| channel.appendLine(`gopls vulncheck (pid: ${p.pid}) is cancelled`); |
| killProcessTree(p); |
| d.dispose(); |
| }); |
| |
| const promise = new Promise<VulncheckReport>((resolve, reject) => { |
| const rl = readline.createInterface({ input: p.stderr }); |
| rl.on('line', (line) => { |
| channel.appendLine(line); |
| const msg = line.match(/^\d+\/\d+\/\d+\s+\d+:\d+:\d+\s+(.*)/); |
| if (msg && msg[1]) { |
| progress.report({ message: msg[1] }); |
| } |
| }); |
| |
| let buf = ''; |
| p.stdout.on('data', (chunk) => { |
| buf += chunk; |
| }); |
| p.stdout.on('close', () => { |
| try { |
| const res: VulncheckReport = JSON.parse(buf); |
| resolve(res); |
| } catch (e) { |
| if (token.isCancellationRequested) { |
| reject('analysis cancelled'); |
| } else { |
| channel.appendLine(buf); |
| reject(`result in unexpected format: ${e}`); |
| } |
| } |
| }); |
| }); |
| return promise; |
| }); |
| return await task; |
| } |
| |
| interface VulncheckReport { |
| // Vulns populated by gopls vulncheck run. |
| Vuln?: Vuln[]; |
| |
| // analysis run information. |
| Pattern?: string; |
| Dir?: string; |
| |
| Start?: Date; |
| Duration?: number; // milliseconds |
| } |
| |
| interface Vuln { |
| ID: string; |
| Details: string; |
| Aliases: string[]; |
| Symbol: string; |
| PkgPath: string; |
| ModPath: string; |
| URL: string; |
| CurrentVersion: string; |
| FixedVersion: string; |
| CallStacks?: CallStack[][]; |
| CallStacksSummary?: string[]; |
| |
| // Derived from call stacks. |
| // TODO(hyangah): add to gopls vulncheck. |
| AffectedPkgs?: string[]; |
| } |
| |
| interface CallStack { |
| Name: string; |
| URI: string; |
| Pos: { |
| line: number; |
| character: number; |
| }; |
| } |
| |
| function getNonce() { |
| let text = ''; |
| const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; |
| for (let i = 0; i < 32; i++) { |
| text += possible.charAt(Math.floor(Math.random() * possible.length)); |
| } |
| return text; |
| } |
| |
| function safeURIParse(s: string): URI | undefined { |
| try { |
| return URI.parse(s); |
| } catch (_) { |
| return undefined; |
| } |
| } |
| |
| // Computes the AffectedPkgs attribute if it's not present. |
| // Exported for testing. |
| // TODO(hyangah): move this logic to gopls vulncheck or govulncheck. |
| export function fillAffectedPkgs(vulns: Vuln[] | undefined): Vuln[] { |
| if (!vulns) return []; |
| |
| const re = new RegExp(/^(\S+)\/([^/\s]+)$/); |
| vulns.forEach((vuln) => { |
| // If it's already set by gopls vulncheck, great! |
| if (vuln.AffectedPkgs) return; |
| |
| const affected = new Set<string>(); |
| vuln.CallStacks?.forEach((cs) => { |
| if (!cs || cs.length === 0) { |
| return; |
| } |
| const name = cs[0].Name || ''; |
| const m = name.match(re); |
| if (!m) { |
| name && affected.add(name); |
| } else { |
| const pkg = m[2] && m[2].split('.')[0]; |
| affected.add(`${m[1]}/${pkg}`); |
| } |
| }); |
| vuln.AffectedPkgs = Array.from(affected); |
| }); |
| return vulns; |
| } |