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
56 changes: 56 additions & 0 deletions src/providers/code-actions/vulnerability.ts
Original file line number Diff line number Diff line change
@@ -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)]
})
}
}
70 changes: 65 additions & 5 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,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}`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnostic.code.value is user-facing in VS Code (it shows up in the Problems UI / diagnostic display). Encoding vulnerability|<fixedInVersion> here will likely confuse users because it looks like an implicit error code.
I’d recommend keeping it as vulnerability and passing the fix-version through a non-user-facing channel (or deriving it from the message) so the Quick Fix can still work without polluting the UI.

: '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)),
},
}
}
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