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
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { openFileInNpmx } from './commands/open-file-in-npmx'
import { openInBrowser } from './commands/open-in-browser'
import { commands, displayName, version } from './generated-meta'
import { UpgradeProvider } from './providers/code-actions/upgrade'
import { VulnerabilityCodeActionProvider } from './providers/code-actions/vulnerability'
import { VersionCompletionItemProvider } from './providers/completion-item/version'
import { useDiagnostics } from './providers/diagnostics'
import { NpmxHoverProvider } from './providers/hover/npmx'
Expand Down Expand Up @@ -53,6 +54,19 @@ 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 disposables = extractorEntries.map(({ pattern }) =>
languages.registerCodeActionsProvider({ pattern }, provider, options),
)

onCleanup(() => Disposable.from(...disposables).dispose())
})

useDiagnostics()

useCommands({
Expand Down
49 changes: 49 additions & 0 deletions src/providers/code-actions/vulnerability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode'
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 {
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 codeAction = new CodeAction(`Update to ${fixedInVersion} to fix vulnerabilities`, CodeActionKind.QuickFix)
codeAction.isPreferred = true
const workspaceEdit = new WorkspaceEdit()
workspaceEdit.replace(document.uri, range, fixedInVersion)
codeAction.edit = workspaceEdit

return codeAction
}

export class VulnerabilityCodeActionProvider implements CodeActionProvider {
provideCodeActions(document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] {
return context.diagnostics.flatMap((diagnostic) => {
if (!isVulnerabilityDiagnostic(diagnostic))
return []

const fixedInVersion = getFixedInVersion(diagnostic)
if (!fixedInVersion)
return []

return [createUpdateVersionAction(document, diagnostic.range, fixedInVersion)]
})
}
}
31 changes: 26 additions & 5 deletions src/providers/diagnostics/rules/vulnerability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { OsvSeverityLevel } from '#utils/api/vulnerability'
import type { DiagnosticRule } from '..'
import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability'
import { npmxPackageUrl } from '#utils/links'
import { isSupportedProtocol, parseVersion } from '#utils/version'
import { formatVersion, isSupportedProtocol, lt, parseVersion } from '#utils/version'
import { DiagnosticSeverity, Uri } from 'vscode'

const DIAGNOSTIC_MAPPING: Record<Exclude<OsvSeverityLevel, 'unknown'>, DiagnosticSeverity> = {
Expand All @@ -12,6 +12,13 @@ const DIAGNOSTIC_MAPPING: Record<Exclude<OsvSeverityLevel, 'unknown'>, Diagnosti
low: DiagnosticSeverity.Hint,
}

function getBestFixedInVersion(fixedInVersions: string[]): string | undefined {
if (!fixedInVersions.length)
return

return fixedInVersions.reduce((best, current) => lt(current, best) ? current : best)
}

export const checkVulnerability: DiagnosticRule = async (dep, pkg) => {
const parsed = parseVersion(dep.version)
if (!parsed || !isSupportedProtocol(parsed.protocol))
Expand All @@ -26,7 +33,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 +52,27 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => {
if (!message.length)
return

const rootVulnerabilitiesFixedIn = vulnerablePackages
.flatMap(({ depth, vulnerabilities }) => {
if (depth !== 'root')
return []

return vulnerabilities.flatMap(({ fixedIn }) => fixedIn ? [fixedIn] : [])
})

const fixedInVersion = getBestFixedInVersion(rootVulnerabilitiesFixedIn)
const messageSuffix = fixedInVersion
? ` Upgrade to ${formatVersion({ ...parsed, semver: 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
3 changes: 3 additions & 0 deletions tests/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ 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 WorkspaceEdit = vscode.WorkspaceEdit
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([

Check failure on line 45 in tests/vulnerability-code-actions.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (windows-latest, lts/*)

tests/vulnerability-code-actions.test.ts > vulnerability code action provider > provides a quick fix when vulnerability message includes upgrade version

AssertionError: expected [ CodeAction{ …(6) } ] to deeply equal [ ObjectContaining{…} ] - Expected + Received [ + CodeAction { + "command": undefined, + "diagnostics": undefined, + "edit": [ + [ + { + "$mid": 1, + "external": "file:///package.json", + "path": "/package.json", + "scheme": "file", + }, + [ + { + "newEol": undefined, + "newText": "1.2.3", + "range": [ { + "character": 0, + "line": 0, + }, + { + "character": 6, + "line": 0, + }, + ], + }, + ], + ], + ], "isPreferred": true, - "title": "Update to ^1.2.3 to fix vulnerabilities", + "kind": CodeActionKind { + "value": "quickfix", + }, + "title": "Update to 1.2.3 to fix vulnerabilities", }, ] ❯ tests/vulnerability-code-actions.test.ts:45:25

Check failure on line 45 in tests/vulnerability-code-actions.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (ubuntu-slim, lts/*)

tests/vulnerability-code-actions.test.ts > vulnerability code action provider > provides a quick fix when vulnerability message includes upgrade version

AssertionError: expected [ CodeAction{ …(6) } ] to deeply equal [ ObjectContaining{…} ] - Expected + Received [ + CodeAction { + "command": undefined, + "diagnostics": undefined, + "edit": [ + [ + { + "$mid": 1, + "external": "file:///package.json", + "path": "/package.json", + "scheme": "file", + }, + [ + { + "newEol": undefined, + "newText": "1.2.3", + "range": [ { + "character": 0, + "line": 0, + }, + { + "character": 6, + "line": 0, + }, + ], + }, + ], + ], + ], "isPreferred": true, - "title": "Update to ^1.2.3 to fix vulnerabilities", + "kind": CodeActionKind { + "value": "quickfix", + }, + "title": "Update to 1.2.3 to fix vulnerabilities", }, ] ❯ tests/vulnerability-code-actions.test.ts:45:25

Check failure on line 45 in tests/vulnerability-code-actions.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (ubuntu-slim, 22)

tests/vulnerability-code-actions.test.ts > vulnerability code action provider > provides a quick fix when vulnerability message includes upgrade version

AssertionError: expected [ CodeAction{ …(6) } ] to deeply equal [ ObjectContaining{…} ] - Expected + Received [ + CodeAction { + "command": undefined, + "diagnostics": undefined, + "edit": [ + [ + { + "$mid": 1, + "external": "file:///package.json", + "path": "/package.json", + "scheme": "file", + }, + [ + { + "newEol": undefined, + "newText": "1.2.3", + "range": [ { + "character": 0, + "line": 0, + }, + { + "character": 6, + "line": 0, + }, + ], + }, + ], + ], + ], "isPreferred": true, - "title": "Update to ^1.2.3 to fix vulnerabilities", + "kind": CodeActionKind { + "value": "quickfix", + }, + "title": "Update to 1.2.3 to fix vulnerabilities", }, ] ❯ tests/vulnerability-code-actions.test.ts:45:25

Check failure on line 45 in tests/vulnerability-code-actions.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (windows-latest, 22)

tests/vulnerability-code-actions.test.ts > vulnerability code action provider > provides a quick fix when vulnerability message includes upgrade version

AssertionError: expected [ CodeAction{ …(6) } ] to deeply equal [ ObjectContaining{…} ] - Expected + Received [ + CodeAction { + "command": undefined, + "diagnostics": undefined, + "edit": [ + [ + { + "$mid": 1, + "external": "file:///package.json", + "path": "/package.json", + "scheme": "file", + }, + [ + { + "newEol": undefined, + "newText": "1.2.3", + "range": [ { + "character": 0, + "line": 0, + }, + { + "character": 6, + "line": 0, + }, + ], + }, + ], + ], + ], "isPreferred": true, - "title": "Update to ^1.2.3 to fix vulnerabilities", + "kind": CodeActionKind { + "value": "quickfix", + }, + "title": "Update to 1.2.3 to fix vulnerabilities", }, ] ❯ tests/vulnerability-code-actions.test.ts:45:25
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)

Check failure on line 86 in tests/vulnerability-code-actions.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (windows-latest, lts/*)

tests/vulnerability-code-actions.test.ts > vulnerability code action provider > does not provide a quick fix when current version already matches fixed version

AssertionError: expected [ CodeAction{ …(6) } ] to have a length of +0 but got 1 - Expected + Received - 0 + 1 ❯ tests/vulnerability-code-actions.test.ts:86:25

Check failure on line 86 in tests/vulnerability-code-actions.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (ubuntu-slim, lts/*)

tests/vulnerability-code-actions.test.ts > vulnerability code action provider > does not provide a quick fix when current version already matches fixed version

AssertionError: expected [ CodeAction{ …(6) } ] to have a length of +0 but got 1 - Expected + Received - 0 + 1 ❯ tests/vulnerability-code-actions.test.ts:86:25

Check failure on line 86 in tests/vulnerability-code-actions.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (ubuntu-slim, 22)

tests/vulnerability-code-actions.test.ts > vulnerability code action provider > does not provide a quick fix when current version already matches fixed version

AssertionError: expected [ CodeAction{ …(6) } ] to have a length of +0 but got 1 - Expected + Received - 0 + 1 ❯ tests/vulnerability-code-actions.test.ts:86:25

Check failure on line 86 in tests/vulnerability-code-actions.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (windows-latest, 22)

tests/vulnerability-code-actions.test.ts > vulnerability code action provider > does not provide a quick fix when current version already matches fixed version

AssertionError: expected [ CodeAction{ …(6) } ] to have a length of +0 but got 1 - Expected + Received - 0 + 1 ❯ tests/vulnerability-code-actions.test.ts:86:25
})

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
Loading