Skip to content

Commit f34f4ac

Browse files
nitodeco9romisecoderabbitai[bot]
authored
feat: add vulnerability quick-fix + hint (#39)
* feat: add vulnerability quick-fix + hint * chore: remove unused variable * refactor: improve vulnerability diagnostic messaging * cleanup * refactor: simplify * Update src/providers/diagnostics/rules/vulnerability.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * refactor: move test to code-actions * test: update * refactor: simplify `getBestFixedInVersion` --------- Co-authored-by: Vida Xie <vida_2020@163.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent e8220cf commit f34f4ac

7 files changed

Lines changed: 161 additions & 5 deletions

File tree

src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { openFileInNpmx } from './commands/open-file-in-npmx'
66
import { openInBrowser } from './commands/open-in-browser'
77
import { commands, displayName, version } from './generated-meta'
88
import { UpgradeProvider } from './providers/code-actions/upgrade'
9+
import { VulnerabilityCodeActionProvider } from './providers/code-actions/vulnerability'
910
import { VersionCompletionItemProvider } from './providers/completion-item/version'
1011
import { useDiagnostics } from './providers/diagnostics'
1112
import { NpmxHoverProvider } from './providers/hover/npmx'
@@ -53,6 +54,19 @@ export const { activate, deactivate } = defineExtension(() => {
5354
onCleanup(() => Disposable.from(...disposables).dispose())
5455
})
5556

57+
watchEffect((onCleanup) => {
58+
if (!config.diagnostics.vulnerability)
59+
return
60+
61+
const provider = new VulnerabilityCodeActionProvider()
62+
const options = { providedCodeActionKinds: [CodeActionKind.QuickFix] }
63+
const disposables = extractorEntries.map(({ pattern }) =>
64+
languages.registerCodeActionsProvider({ pattern }, provider, options),
65+
)
66+
67+
onCleanup(() => Disposable.from(...disposables).dispose())
68+
})
69+
5670
useDiagnostics()
5771

5872
useCommands({
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode'
2+
import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode'
3+
4+
const FIXED_VERSION_MESSAGE_PATTERN = / Upgrade to (?<fixedInVersion>\S+) to fix\.$/
5+
6+
function getDiagnosticCodeValue(diagnostic: Diagnostic): string | null {
7+
if (typeof diagnostic.code === 'string')
8+
return diagnostic.code
9+
10+
if (typeof diagnostic.code === 'object' && typeof diagnostic.code.value === 'string')
11+
return diagnostic.code.value
12+
13+
return null
14+
}
15+
16+
function isVulnerabilityDiagnostic(diagnostic: Diagnostic): boolean {
17+
return getDiagnosticCodeValue(diagnostic) === 'vulnerability'
18+
}
19+
20+
function getFixedInVersion(diagnostic: Diagnostic): string | null {
21+
const fixedInVersionMatch = FIXED_VERSION_MESSAGE_PATTERN.exec(diagnostic.message)
22+
const fixedInVersion = fixedInVersionMatch?.groups?.fixedInVersion
23+
return fixedInVersion && fixedInVersion.length > 0 ? fixedInVersion : null
24+
}
25+
26+
function createUpdateVersionAction(document: TextDocument, range: Range, fixedInVersion: string): CodeAction {
27+
const codeAction = new CodeAction(`Update to ${fixedInVersion} to fix vulnerabilities`, CodeActionKind.QuickFix)
28+
codeAction.isPreferred = true
29+
const workspaceEdit = new WorkspaceEdit()
30+
workspaceEdit.replace(document.uri, range, fixedInVersion)
31+
codeAction.edit = workspaceEdit
32+
33+
return codeAction
34+
}
35+
36+
export class VulnerabilityCodeActionProvider implements CodeActionProvider {
37+
provideCodeActions(document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] {
38+
return context.diagnostics.flatMap((diagnostic) => {
39+
if (!isVulnerabilityDiagnostic(diagnostic))
40+
return []
41+
42+
const fixedInVersion = getFixedInVersion(diagnostic)
43+
if (!fixedInVersion)
44+
return []
45+
46+
return [createUpdateVersionAction(document, diagnostic.range, fixedInVersion)]
47+
})
48+
}
49+
}

src/providers/diagnostics/rules/vulnerability.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { OsvSeverityLevel } from '#utils/api/vulnerability'
1+
import type { OsvSeverityLevel, PackageVulnerabilityInfo } from '#utils/api/vulnerability'
22
import type { DiagnosticRule } from '..'
33
import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability'
44
import { npmxPackageUrl } from '#utils/links'
5-
import { isSupportedProtocol, parseVersion } from '#utils/version'
5+
import { formatVersion, isSupportedProtocol, lt, parseVersion } from '#utils/version'
66
import { DiagnosticSeverity, Uri } from 'vscode'
77

88
const DIAGNOSTIC_MAPPING: Record<Exclude<OsvSeverityLevel, 'unknown'>, DiagnosticSeverity> = {
@@ -12,6 +12,19 @@ const DIAGNOSTIC_MAPPING: Record<Exclude<OsvSeverityLevel, 'unknown'>, Diagnosti
1212
low: DiagnosticSeverity.Hint,
1313
}
1414

15+
function getBigestFixedInVersion(vulnerablePackages: PackageVulnerabilityInfo[]): string | undefined {
16+
let bigest: string | undefined
17+
for (const { depth, vulnerabilities } of vulnerablePackages) {
18+
if (depth !== 'root')
19+
continue
20+
for (const { fixedIn } of vulnerabilities) {
21+
if (fixedIn && (!bigest || lt(bigest, fixedIn)))
22+
bigest = fixedIn
23+
}
24+
}
25+
return bigest
26+
}
27+
1528
export const checkVulnerability: DiagnosticRule = async (dep, pkg) => {
1629
const parsed = parseVersion(dep.version)
1730
if (!parsed || !isSupportedProtocol(parsed.protocol))
@@ -26,7 +39,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => {
2639
if (!result)
2740
return
2841

29-
const { totalCounts } = result
42+
const { totalCounts, vulnerablePackages } = result
3043
const message: string[] = []
3144
let severity: DiagnosticSeverity | null = null
3245

@@ -45,10 +58,15 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => {
4558
if (!message.length)
4659
return
4760

61+
const fixedInVersion = getBigestFixedInVersion(vulnerablePackages)
62+
const messageSuffix = fixedInVersion
63+
? ` Upgrade to ${formatVersion({ ...parsed, semver: fixedInVersion })} to fix.`
64+
: ''
65+
4866
return {
4967
node: dep.versionNode,
50-
message: `This version has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}`,
51-
severity: DiagnosticSeverity.Error,
68+
message: `This version has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`,
69+
severity: severity ?? DiagnosticSeverity.Error,
5270
code: {
5371
value: 'vulnerability',
5472
target: Uri.parse(npmxPackageUrl(dep.name, semver)),

src/utils/api/vulnerability.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface VulnerabilitySummary {
2323
severity: OsvSeverityLevel
2424
aliases: string[]
2525
url: string
26+
fixedIn?: string
2627
}
2728

2829
/** Depth in dependency tree */

tests/__mocks__/vscode.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export const Range = vscode.Range
99
export const Position = vscode.Position
1010
export const Location = vscode.Location
1111
export const Selection = vscode.Selection
12+
export const CodeAction = vscode.CodeAction
13+
export const CodeActionKind = vscode.CodeActionKind
14+
export const WorkspaceEdit = vscode.WorkspaceEdit
1215
export const ThemeColor = vscode.ThemeColor
1316
export const ThemeIcon = vscode.ThemeIcon
1417
export const TreeItem = vscode.TreeItem
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { CodeActionContext, Diagnostic, TextDocument } from 'vscode'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { Range, Uri } from 'vscode'
4+
import { VulnerabilityCodeActionProvider } from '../../src/providers/code-actions/vulnerability'
5+
6+
function createDiagnostic(options: { code: string | { value: string }, message: string }): Diagnostic {
7+
return {
8+
code: options.code,
9+
message: options.message,
10+
range: new Range(0, 0, 0, 6),
11+
} as Diagnostic
12+
}
13+
14+
function createTextDocument(versionText: string): TextDocument {
15+
return {
16+
uri: Uri.parse('file:///package.json'),
17+
getText: vi.fn(() => versionText),
18+
} as unknown as TextDocument
19+
}
20+
21+
function createCodeActionContext(diagnostics: Diagnostic[]): CodeActionContext {
22+
return {
23+
diagnostics,
24+
triggerKind: 1 as CodeActionContext['triggerKind'],
25+
only: undefined,
26+
}
27+
}
28+
29+
describe('vulnerability code action provider', () => {
30+
it('provides a quick fix when vulnerability message includes upgrade version', () => {
31+
const provider = new VulnerabilityCodeActionProvider()
32+
const textDocument = createTextDocument('^1.0.0')
33+
34+
const diagnostic = createDiagnostic({
35+
code: { value: 'vulnerability' },
36+
message: 'This version has 1 high vulnerability. Upgrade to ^1.2.3 to fix.',
37+
})
38+
39+
const codeActions = provider.provideCodeActions(
40+
textDocument,
41+
diagnostic.range,
42+
createCodeActionContext([diagnostic]),
43+
)
44+
45+
expect(codeActions).toEqual([
46+
expect.objectContaining({
47+
title: 'Update to ^1.2.3 to fix vulnerabilities',
48+
isPreferred: true,
49+
}),
50+
])
51+
})
52+
53+
it('does not provide a quick fix when vulnerability message has no upgrade target', () => {
54+
const provider = new VulnerabilityCodeActionProvider()
55+
const textDocument = createTextDocument('^1.0.0')
56+
57+
const diagnostic = createDiagnostic({
58+
code: { value: 'vulnerability' },
59+
message: 'This version has 1 high vulnerability.',
60+
})
61+
62+
const codeActions = provider.provideCodeActions(
63+
textDocument,
64+
diagnostic.range,
65+
createCodeActionContext([diagnostic]),
66+
)
67+
68+
expect(codeActions).toHaveLength(0)
69+
})
70+
})

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default defineConfig({
99
alias: {
1010
'#constants': join(rootDir, '/src/constants.ts'),
1111
'#state': join(rootDir, '/src/state.ts'),
12+
'#utils': join(rootDir, '/src/utils'),
1213
'#types/*': join(rootDir, '/src/types/*'),
1314
'#utils/*': join(rootDir, '/src/utils/*'),
1415
'vscode': join(rootDir, '/tests/__mocks__/vscode.ts'),

0 commit comments

Comments
 (0)