Skip to content
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ export const NPMX_DEV = 'https://npmx.dev'
export const NPMX_DEV_API = `${NPMX_DEV}/api`

export const SPACER = ' '

export const VULNERABILITY_FETCH_TIMEOUT_MS = 3_000
Comment thread
nitodeco marked this conversation as resolved.
Outdated
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'
Comment thread
9romise marked this conversation as resolved.

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
}
Comment thread
nitodeco marked this conversation as resolved.
Outdated

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}`
Comment thread
nitodeco marked this conversation as resolved.
Outdated
: '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