Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions src/providers/code-actions/vulnerability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode'
import { formatVersion, parseVersion } from '#utils/package'
import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode'

const FIXED_VERSION_MESSAGE_PATTERN = / Upgrade to (?<fixedInVersion>\S+) to fix\.$/

function getDiagnosticCodeValue(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 isVulnerabilityDiagnostic(diagnostic: Diagnostic): boolean {
return getDiagnosticCodeValue(diagnostic) === 'vulnerability'
}

function getFixedInVersion(diagnostic: Diagnostic): string | null {
if (!isVulnerabilityDiagnostic(diagnostic))
return null

const fixedInVersionMatch = FIXED_VERSION_MESSAGE_PATTERN.exec(diagnostic.message)
const fixedInVersion = fixedInVersionMatch?.groups?.fixedInVersion
return fixedInVersion && 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)]
})
}
}
65 changes: 61 additions & 4 deletions src/providers/diagnostics/rules/vulnerability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,52 @@ const DIAGNOSTIC_MAPPING: Record<Exclude<OsvSeverityLevel, 'unknown'>, 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))
Expand All @@ -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

Expand All @@ -45,13 +91,24 @@ 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 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)),
target: Uri.parse(npmxPackageUrl(dep.name, targetVersion)),
},
}
}
1 change: 1 addition & 0 deletions src/utils/api/vulnerability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface VulnerabilitySummary {
severity: OsvSeverityLevel
aliases: string[]
url: string
fixedIn?: string
}

/** Depth in dependency tree */
Expand Down
6 changes: 6 additions & 0 deletions tests/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ const vscode = createVSCodeMock(vi)

export const Uri = vscode.Uri
export const workspace = vscode.workspace
export const languages = vscode.languages
export const Range = vscode.Range
export const Position = vscode.Position
export const Location = vscode.Location
export const Selection = vscode.Selection
export const CodeAction = vscode.CodeAction
export const CodeActionKind = vscode.CodeActionKind
export const CodeActionTriggerKind = vscode.CodeActionTriggerKind
export const WorkspaceEdit = vscode.WorkspaceEdit
export const DiagnosticSeverity = vscode.DiagnosticSeverity
export const ThemeColor = vscode.ThemeColor
export const ThemeIcon = vscode.ThemeIcon
export const TreeItem = vscode.TreeItem
Expand Down
106 changes: 106 additions & 0 deletions tests/vulnerability-code-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { CodeActionContext, Diagnostic, TextDocument } from 'vscode'
import { describe, expect, it, vi } from 'vitest'
import { Range, Uri } from 'vscode'
import { VulnerabilityCodeActionProvider } from '../src/providers/code-actions/vulnerability'

function createDiagnostic(options: { code: string | { value: string }, message: string }): Diagnostic {
return {
code: options.code,
message: options.message,
range: new Range(0, 0, 0, 6),
} as Diagnostic
}

function createTextDocument(versionText: string): TextDocument {
return {
uri: Uri.parse('file:///package.json'),
getText: vi.fn(() => versionText),
} as unknown as TextDocument
}

function createCodeActionContext(diagnostics: Diagnostic[]): CodeActionContext {
return {
diagnostics,
triggerKind: 1 as CodeActionContext['triggerKind'],
only: undefined,
}
}

describe('vulnerability code action provider', () => {
it('provides a quick fix when vulnerability message includes upgrade version', () => {
const provider = new VulnerabilityCodeActionProvider()
const textDocument = createTextDocument('^1.0.0')

const diagnostic = createDiagnostic({
code: { value: 'vulnerability' },
message: 'This version has 1 high vulnerability. Upgrade to 1.2.3 to fix.',
})

const codeActions = provider.provideCodeActions(
textDocument,
diagnostic.range,
createCodeActionContext([diagnostic]),
)

expect(codeActions).toEqual([
expect.objectContaining({
title: 'Update to ^1.2.3 to fix vulnerabilities',
isPreferred: true,
}),
])
})

it('does not provide a quick fix when vulnerability message has no upgrade target', () => {
const provider = new VulnerabilityCodeActionProvider()
const textDocument = createTextDocument('^1.0.0')

const diagnostic = createDiagnostic({
code: { value: 'vulnerability' },
message: 'This version has 1 high vulnerability.',
})

const codeActions = provider.provideCodeActions(
textDocument,
diagnostic.range,
createCodeActionContext([diagnostic]),
)

expect(codeActions).toHaveLength(0)
})

it('does not provide a quick fix when current version already matches fixed version', () => {
const provider = new VulnerabilityCodeActionProvider()
const textDocument = createTextDocument('~1.2.3')

const diagnostic = createDiagnostic({
code: { value: 'vulnerability' },
message: 'This version has 1 high vulnerability. Upgrade to 1.2.3 to fix.',
})

const codeActions = provider.provideCodeActions(
textDocument,
diagnostic.range,
createCodeActionContext([diagnostic]),
)

expect(codeActions).toHaveLength(0)
})

it('does not rely on encoded vulnerability code values', () => {
const provider = new VulnerabilityCodeActionProvider()
const textDocument = createTextDocument('^1.0.0')

const diagnostic = createDiagnostic({
code: { value: 'vulnerability|1.2.3' },
message: 'This version has 1 high vulnerability. Upgrade to 1.2.3 to fix.',
})

const codeActions = provider.provideCodeActions(
textDocument,
diagnostic.range,
createCodeActionContext([diagnostic]),
)

expect(codeActions).toHaveLength(0)
})
})
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default defineConfig({
alias: {
'#constants': join(rootDir, '/src/constants.ts'),
'#state': join(rootDir, '/src/state.ts'),
'#utils': join(rootDir, '/src/utils'),
'#types/*': join(rootDir, '/src/types/*'),
'#utils/*': join(rootDir, '/src/utils/*'),
'vscode': join(rootDir, '/tests/__mocks__/vscode.ts'),
Expand Down