diff --git a/src/index.ts b/src/index.ts index a6283e6..9c694af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,13 @@ import { VERSION_TRIGGER_CHARACTERS, } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { Disposable, languages } from 'vscode' +import { CodeActionKind, Disposable, languages } from 'vscode' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' import { PackageJsonExtractor } from './extractors/package-json' import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml' import { commands, displayName, version } from './generated-meta' +import { VulnerabilityCodeActionProvider } from './providers/code-actions/vulnerability' import { VersionCompletionItemProvider } from './providers/completion-item/version' import { registerDiagnosticCollection } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' @@ -61,6 +62,20 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).dispose()) }) + watchEffect((onCleanup) => { + if (!config.diagnostics.vulnerability) + return + + const provider = new VulnerabilityCodeActionProvider() + const options = { providedCodeActionKinds: [CodeActionKind.QuickFix] } + const disposable = Disposable.from( + languages.registerCodeActionsProvider({ pattern: PACKAGE_JSON_PATTERN }, provider, options), + languages.registerCodeActionsProvider({ pattern: PNPM_WORKSPACE_PATTERN }, provider, options), + ) + + onCleanup(() => disposable.dispose()) + }) + registerDiagnosticCollection({ [PACKAGE_JSON_BASENAME]: packageJsonExtractor, [PNPM_WORKSPACE_BASENAME]: pnpmWorkspaceYamlExtractor, diff --git a/src/providers/code-actions/vulnerability.ts b/src/providers/code-actions/vulnerability.ts new file mode 100644 index 0000000..a0d31bf --- /dev/null +++ b/src/providers/code-actions/vulnerability.ts @@ -0,0 +1,56 @@ +import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode' +import { formatVersion, parseVersion } from '#utils/package' +import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' + +function getVulnerabilityCodeValue(diagnostic: Diagnostic): string | null { + if (typeof diagnostic.code === 'string') + return diagnostic.code + + if (typeof diagnostic.code === 'object' && typeof diagnostic.code.value === 'string') + return diagnostic.code.value + + return null +} + +function getFixedInVersion(diagnostic: Diagnostic): string | null { + const vulnerabilityCodeValue = getVulnerabilityCodeValue(diagnostic) + if (!vulnerabilityCodeValue || !vulnerabilityCodeValue.startsWith('vulnerability|')) + return null + + const fixedInVersion = vulnerabilityCodeValue.slice('vulnerability|'.length) + return fixedInVersion.length > 0 ? fixedInVersion : null +} + +function createUpdateVersionAction(document: TextDocument, range: Range, fixedInVersion: string): CodeAction { + const currentVersion = document.getText(range) + const parsedCurrentVersion = parseVersion(currentVersion) + const formattedFixedVersion = parsedCurrentVersion + ? formatVersion({ ...parsedCurrentVersion, semver: fixedInVersion }) + : fixedInVersion + + const codeAction = new CodeAction(`Update to ${formattedFixedVersion} to fix vulnerabilities`, CodeActionKind.QuickFix) + codeAction.isPreferred = true + const workspaceEdit = new WorkspaceEdit() + workspaceEdit.replace(document.uri, range, formattedFixedVersion) + codeAction.edit = workspaceEdit + + return codeAction +} + +export class VulnerabilityCodeActionProvider implements CodeActionProvider { + provideCodeActions(document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] { + return context.diagnostics.flatMap((diagnostic) => { + const fixedInVersion = getFixedInVersion(diagnostic) + if (!fixedInVersion) + return [] + + const currentVersion = document.getText(diagnostic.range) + const currentSemver = parseVersion(currentVersion)?.semver + const fixedSemver = parseVersion(fixedInVersion)?.semver ?? fixedInVersion + if (currentSemver && currentSemver === fixedSemver) + return [] + + return [createUpdateVersionAction(document, diagnostic.range, fixedInVersion)] + }) + } +} diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 01cbc08..268b415 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -12,6 +12,52 @@ const DIAGNOSTIC_MAPPING: Record, Diagnosti low: DiagnosticSeverity.Hint, } +// TODO: remove and import once #36 is merged +function comparePrerelease(a: string, b: string): number { + const pa = a.split('.') + const pb = b.split('.') + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + if (i >= pa.length) + return -1 + if (i >= pb.length) + return 1 + const na = Number(pa[i]) + const nb = Number(pb[i]) + if (!Number.isNaN(na) && !Number.isNaN(nb)) { + if (na !== nb) + return na - nb + } else if (pa[i] !== pb[i]) { + return pa[i] < pb[i] ? -1 : 1 + } + } + return 0 +} + +// TODO: remove and import once #36 is merged +function lt(a: string, b: string): boolean { + const [coreA, preA] = a.split('-', 2) + const [coreB, preB] = b.split('-', 2) + const partsA = coreA.split('.').map(Number) + const partsB = coreB.split('.').map(Number) + for (let i = 0; i < 3; i++) { + const diff = (partsA[i] || 0) - (partsB[i] || 0) + if (diff !== 0) + return diff < 0 + } + if (preA && !preB) + return true + if (!preA || !preB) + return false + return comparePrerelease(preA, preB) < 0 +} + +function getBestFixedInVersion(fixedInVersions: string[]): string | undefined { + if (!fixedInVersions.length) + return + + return fixedInVersions.reduce((best, current) => lt(best, current) ? current : best) +} + export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { const parsed = parseVersion(dep.version) if (!parsed || !isSupportedProtocol(parsed.protocol)) @@ -26,7 +72,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { if (!result) return - const { totalCounts } = result + const { totalCounts, vulnerablePackages } = result const message: string[] = [] let severity: DiagnosticSeverity | null = null @@ -45,13 +91,27 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { if (!message.length) return + const rootVulnerabilitiesFixedIn = vulnerablePackages + .filter((vulnerablePackage) => vulnerablePackage.depth === 'root') + .flatMap((vulnerablePackage) => vulnerablePackage.vulnerabilities) + .map((vulnerability) => vulnerability.fixedIn) + .filter((fixedIn): fixedIn is string => Boolean(fixedIn)) + const fixedInVersion = getBestFixedInVersion(rootVulnerabilitiesFixedIn) + const messageSuffix = fixedInVersion + ? ` Upgrade to ${parsed.prefix}${fixedInVersion} to fix.` + : '' + const vulnerabilityCode = fixedInVersion + ? `vulnerability|${fixedInVersion}` + : 'vulnerability' + const targetVersion = fixedInVersion ?? semver + return { node: dep.versionNode, - message: `This version has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}`, - severity: DiagnosticSeverity.Error, + message: `This version has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, + severity: severity ?? DiagnosticSeverity.Error, code: { - value: 'vulnerability', - target: Uri.parse(npmxPackageUrl(dep.name, semver)), + value: vulnerabilityCode, + target: Uri.parse(npmxPackageUrl(dep.name, targetVersion)), }, } } diff --git a/src/utils/api/vulnerability.ts b/src/utils/api/vulnerability.ts index c9ab5fd..7f34fc9 100644 --- a/src/utils/api/vulnerability.ts +++ b/src/utils/api/vulnerability.ts @@ -23,6 +23,7 @@ export interface VulnerabilitySummary { severity: OsvSeverityLevel aliases: string[] url: string + fixedIn?: string } /** Depth in dependency tree */