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}`
: '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