src/goVulncheck.ts (257 lines of code) (raw):

/*--------------------------------------------------------- * Copyright 2022 The Go Authors. All rights reserved. * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------*/ import path = require('path'); import vscode = require('vscode'); import { URI } from 'vscode-uri'; import { getGoConfig } from './config'; function moduleVersion(mod: string, ver: string | undefined) { if (!ver) { return 'N/A'; } if (mod === 'stdlib') { return `go${ver.replace(/^(v|go)/, '')}`; } return `${mod}@${ver}`; } // writeVulns generates human-readable vulnerability report from the VulncheckReport // and write to the outputChannel. export function writeVulns( res: VulncheckReport | undefined | null, outputChannel: { appendLine(value: string): void } ) { outputChannel.appendLine(''); if (!res) { outputChannel.appendLine('Error - invalid vulncheck result.'); // TODO(hyangah): ask to open an issue. return; } if (!res.Vulns || res.Vulns.length === 0) { outputChannel.appendLine('No vulnerability found.'); return; } const affecting = res.Vulns.filter((v) => { return v.Modules?.some((m) => { return m.Packages?.some((p) => { return p.CallStacks?.some((cs) => { return cs.Frames && cs.Frames.length > 0; }); }); }); }); const unaffecting = res.Vulns.filter((v) => !affecting.includes(v)); switch (affecting.length) { case 0: outputChannel.appendLine('No vulnerability found.'); break; case 1: outputChannel.appendLine(`Found ${affecting.length} affecting vulnerability.`); outputChannel.appendLine('-'.repeat(80)); break; default: outputChannel.appendLine(`Found ${affecting.length} affecting vulnerabilities.`); outputChannel.appendLine('-'.repeat(80)); break; } affecting.forEach((vuln) => { outputChannel.appendLine(`⚠ ${vuln.OSV.id} (https://pkg.go.dev/vuln/${vuln.OSV.id})`); const desc = (vuln.OSV.details || '').trimRight(); const aliases = vuln.OSV.aliases?.length ? ` (${vuln.OSV.aliases.join(', ')})` : ''; outputChannel.appendLine(`\n${desc}${aliases}\n`); vuln.Modules?.forEach((mod) => { outputChannel.appendLine(`Found Version: ${moduleVersion(mod.Path, mod.FoundVersion)}`); outputChannel.appendLine(`Fixed Version: ${moduleVersion(mod.Path, mod.FixedVersion)}`); mod.Packages?.forEach((pkg) => { outputChannel.appendLine('\nCall stacks in your code:'); pkg.CallStacks?.forEach((cs, index) => { // TODO: the position info embedded in the cs.Summary is relative to // the directory gopls ran the vulnchek. // Instead replace with workspace-relative paths. outputChannel.appendLine(`- ${cs.Summary}`); // Print the first trace (index === 0) as an example. // TODO(hyangah): allow users to see example traces for all detected vulnerable symbols. if (index === 0 && cs.Frames) { const last = cs.Frames.length - 1; cs.Frames?.forEach((f, index) => { // Skip the last frame that just carries the vulnerable symbol. // This info is already included in cs.Summary. if (last === index) return; const line = f.Position?.Line || 1; // TODO: shorten f.Position.Filename (e.g. workspace relative path, and home directory ~ relative path) const pos = f.Position?.Filename ? `${f.Position.Filename}:${line}` : ' - '; const name = f.RecvType ? `${f.RecvType}.${f.FuncName}` : `${f.PkgPath}.${f.FuncName}`; outputChannel.appendLine(`\t${name}\n\t\t(${pos})`); }); } }); }); }); outputChannel.appendLine('-'.repeat(80)); }); if (unaffecting.length) { outputChannel.appendLine(` # The vulnerabilities below are in packages that you import, but your code does # not appear to call any vulnerable functions. You may not need to take any # action. See https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck for details. `); switch (unaffecting.length) { case 1: outputChannel.appendLine(`Found ${unaffecting.length} unused vulnerability.`); break; default: outputChannel.appendLine(`Found ${unaffecting.length} unused vulnerabilities.`); break; } outputChannel.appendLine('-'.repeat(80)); } unaffecting.forEach((vuln) => { outputChannel.appendLine(`ⓘ ${vuln.OSV.id} (https://pkg.go.dev/vuln/${vuln.OSV.id})`); const desc = (vuln.OSV.details || '').trimRight(); const aliases = vuln.OSV.aliases?.length ? ` (${vuln.OSV.aliases.join(', ')})` : ''; outputChannel.appendLine(`\n${desc}${aliases}\n`); vuln.Modules?.forEach((mod) => { outputChannel.appendLine(`Found Version: ${moduleVersion(mod.Path, mod.FoundVersion)}`); outputChannel.appendLine(`Fixed Version: ${moduleVersion(mod.Path, mod.FixedVersion)}`); mod.Packages?.forEach((pkg) => { outputChannel.appendLine(`Package: ${pkg.Path}`); }); }); outputChannel.appendLine('-'.repeat(80)); }); } // VulncheckReport is the JSON data type of gopls's vulncheck result. export interface VulncheckReport { // Vulns populated by gopls vulncheck run. Vulns?: Vuln[]; Mode?: 'govulncheck' | 'imports'; } // Vuln represents a single OSV entry. interface Vuln { // OSV contains all data from the OSV entry for this vulnerability. OSV: OSVEntry; // Modules contains all of the modules in the OSV entry where a // vulnerable package is imported by the target source code or binary. // // For example, a module M with two packages M/p1 and M/p2, where only p1 // is vulnerable, will appear in this list if and only if p1 is imported by // the target source code or binary. Modules: Module[]; AffectedPackages?: string[]; } interface OSVEntry { id: string; published?: string; aliases?: string[]; details?: string; affected?: Affected[]; } interface Affected { package: Package; ecosystem_specific?: EcosystemSpecific; } interface EcosystemSpecificImport { path: string; goos?: string[]; goarch?: string[]; symbols?: string[]; } interface EcosystemSpecific { imports?: EcosystemSpecificImport[]; } interface Package { name: string; } interface Module { // Path is the module path of the module containing the vulnerability. // // Importable packages in the standard library will have the path "stdlib". Path: string; // FoundVersion is the module version where the vulnerability was found. FoundVersion?: string; // FixedVersion is the module version where the vulnerability was // fixed. If there are multiple fixed versions in the OSV report, this will // be the latest fixed version. // // This is empty if a fix is not available. FixedVersion?: string; // Packages contains all the vulnerable packages in OSV entry that are // imported by the target source code or binary. // // For example, given a module M with two packages M/p1 and M/p2, where // both p1 and p2 are vulnerable, p1 and p2 will each only appear in this // list they are individually imported by the target source code or binary. Packages?: Package[]; } interface Package { // Path is the import path of the package containing the vulnerability. Path: string; // CallStacks contains a representative call stack for each // vulnerable symbol that is called. // // For vulnerabilities found from binary analysis, only CallStack.Symbol // will be provided. // // For non-affecting vulnerabilities reported from the source mode // analysis, this will be empty. CallStacks?: CallStack[]; } interface CallStack { // Symbol is the name of the detected vulnerable function // or method. // // This follows the naming convention in the OSV report. Symbol?: string; // Summary is a one-line description of the callstack, used by the // default govulncheck mode. // // Example: module3.main calls github.com/shiyanhui/dht.DHT.Run Summary?: string; // Frames contains an entry for each stack in the call stack. // // Frames are sorted starting from the entry point to the // imported vulnerable symbol. The last frame in Frames should match // Symbol. Frames?: StackFrame[]; } interface StackFrame { // PackagePath is the import path. PkgPath: string; // FuncName is the function name. FuncName?: string; // RecvType is the fully qualified receiver type, // if the called symbol is a method. // // The client can create the final symbol name by // prepending RecvType to FuncName. RecvType?: string; // Position describes an arbitrary source position // including the file, line, and column location. // A Position is valid if the line number is > 0. Position?: Position; } interface Position { Filename?: string; // filename, if any Offset?: number; // offset, starting at 0 Line?: number; // line number, starting at 1 Column?: number; // column number, starting at 1 (byte count) } // VulncheckOutputLinkProvider linkifies govulncheck output. export class VulncheckOutputLinkProvider implements vscode.DocumentLinkProvider { static activate(ctx: Pick<vscode.ExtensionContext, 'subscriptions'>) { ctx.subscriptions.push( vscode.languages.registerDocumentLinkProvider( { language: 'govulncheck' }, new VulncheckOutputLinkProvider() ) ); } provideDocumentLinks( document: vscode.TextDocument, // eslint-disable-next-line @typescript-eslint/no-unused-vars _token: vscode.CancellationToken ): vscode.ProviderResult<vscode.DocumentLink[]> { try { return this.unsafeProvideDocumentLinks(document); } catch (e) { console.log(`failed to linkify govulncheck output result: ${e}`); } return []; } unsafeProvideDocumentLinks(document: vscode.TextDocument): vscode.ProviderResult<vscode.DocumentLink[]> { const ret = [] as vscode.DocumentLink[]; let cwd = ''; for (let i = 0; i < document.lineCount; i++) { const readLine = document.lineAt(i); // govulncheck ./... for file:///foo/go.mod. const cmdPattern = readLine.text.match(/^govulncheck\s+\S+\s+for\s+(file:.*\.mod)/); if (cmdPattern && cmdPattern[1]) { cwd = path.dirname(vscode.Uri.parse(cmdPattern[1]).fsPath); continue; } // Found Version: and Fixed Version: const foundOrFixedVersionPattern = readLine.text.match(/^(?:Found|Fixed) Version:\s+(\S+@\S+)$/); if (foundOrFixedVersionPattern && foundOrFixedVersionPattern[1]) { const modVersion = foundOrFixedVersionPattern[1]; const start = readLine.text.indexOf(modVersion); const end = start + modVersion.length; const link = new vscode.DocumentLink( new vscode.Range(i, start, i, end), vscode.Uri.parse(`https://pkg.go.dev/${modVersion}`) ); link.tooltip = `https://pkg.go.dev/${modVersion}`; ret.push(link); continue; } // Position at file (e.g. file.go:1:2) const filePosPattern = readLine.text.match(/(?:-\s+|\s+\()(\S+\.go):(\d+)(?::(\d+)){0,1}/); if (filePosPattern && filePosPattern[1]) { let fname = filePosPattern[1]; if (!path.isAbsolute(fname)) { fname = path.join(cwd, fname); } if (path.isAbsolute(fname)) { const line = filePosPattern[2]; const col = filePosPattern[3]; const fragment = col ? { fragment: `L${line},${col}` } : { fragment: `L${line}` }; const uri = URI.file(fname).with(fragment); const start = readLine.text.indexOf(filePosPattern[1]); const end = readLine.text.indexOf(filePosPattern[0]) + filePosPattern[0].length; const link = new vscode.DocumentLink(new vscode.Range(i, start, i, end), uri); ret.push(link); } continue; } } return ret; } } export const toggleVulncheckCommandFactory = () => () => { const editor = vscode.window.activeTextEditor; const documentUri = editor?.document.uri; toggleVulncheckCommand(documentUri); }; function toggleVulncheckCommand(uri?: URI) { const goCfgName = 'diagnostic.vulncheck'; const cfg = getGoConfig(uri); const { globalValue, workspaceValue, workspaceFolderValue } = cfg.inspect(goCfgName) || {}; if (workspaceFolderValue) { const newValue = workspaceFolderValue === 'Imports' ? 'Off' : 'Imports'; cfg.update(goCfgName, newValue); return; } if (workspaceValue) { const newValue = workspaceValue === 'Imports' ? 'Off' : 'Imports'; cfg.update(goCfgName, newValue, false); return; } if (globalValue) { const newValue = globalValue === 'Imports' ? 'Off' : 'Imports'; cfg.update(goCfgName, newValue, true); return; } cfg.update(goCfgName, 'Imports'); }