Skip to content
Merged
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
74 changes: 33 additions & 41 deletions app/composables/useNpmRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
PackageVersionInfo,
} from '#shared/types'
import type { ReleaseType } from 'semver'
import { mapWithConcurrency } from '#shared/utils/async'
import { maxSatisfying, prerelease, major, minor, diff, gt, compare } from 'semver'
import { isExactVersion } from '~/utils/versions'
import { extractInstallScriptsInfo } from '~/utils/install-scripts'
Expand Down Expand Up @@ -546,34 +547,28 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {

// Fetch packuments and downloads in parallel
const [packuments, downloads] = await Promise.all([
// Fetch packuments in parallel (with concurrency limit)
// Fetch packuments with concurrency limit
(async () => {
const concurrency = 10
const results: MinimalPackument[] = []
for (let i = 0; i < packageNames.length; i += concurrency) {
const batch = packageNames.slice(i, i + concurrency)
const batchResults = await Promise.all(
batch.map(async name => {
try {
const encoded = encodePackageName(name)
const { data: pkg } = await cachedFetch<MinimalPackument>(
`${NPM_REGISTRY}/${encoded}`,
{ signal },
)
return pkg
} catch {
return null
}
}),
)
for (const pkg of batchResults) {
// Filter out any unpublished packages (missing dist-tags)
if (pkg && pkg['dist-tags']) {
results.push(pkg)
const results = await mapWithConcurrency(
packageNames,
async name => {
try {
const encoded = encodePackageName(name)
const { data: pkg } = await cachedFetch<MinimalPackument>(
`${NPM_REGISTRY}/${encoded}`,
{ signal },
)
return pkg
} catch {
return null
}
}
}
return results
},
10,
)
// Filter out any unpublished packages (missing dist-tags)
return results.filter(
(pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
)
})(),
// Fetch downloads in bulk
fetchBulkDownloads(packageNames, { signal }),
Expand Down Expand Up @@ -772,23 +767,20 @@ export function useOutdatedDependencies(
return
}

const results: Record<string, OutdatedDependencyInfo> = {}
const entries = Object.entries(deps)
const batchSize = 5

for (let i = 0; i < entries.length; i += batchSize) {
const batch = entries.slice(i, i + batchSize)
const batchResults = await Promise.all(
batch.map(async ([name, constraint]) => {
const info = await checkDependencyOutdated(cachedFetch, name, constraint)
return [name, info] as const
}),
)
const batchResults = await mapWithConcurrency(
entries,
async ([name, constraint]) => {
const info = await checkDependencyOutdated(cachedFetch, name, constraint)
return [name, info] as const
},
5,
)

for (const [name, info] of batchResults) {
if (info) {
results[name] = info
}
const results: Record<string, OutdatedDependencyInfo> = {}
for (const [name, info] of batchResults) {
if (info) {
results[name] = info
}
}

Expand Down
140 changes: 106 additions & 34 deletions server/utils/dependency-analysis.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
OsvQueryResponse,
OsvBatchResponse,
OsvVulnerability,
OsvSeverityLevel,
VulnerabilitySummary,
Expand All @@ -8,31 +9,83 @@ import type {
VulnerabilityTreeResult,
DeprecatedPackageInfo,
} from '#shared/types/dependency-analysis'
import { mapWithConcurrency } from '#shared/utils/async'
import { resolveDependencyTree } from './dependency-resolver'

/** Result of a single OSV query */
type OsvQueryResult = { status: 'ok'; data: PackageVulnerabilityInfo | null } | { status: 'error' }
/** Maximum concurrent requests for fetching vulnerability details */
const OSV_DETAIL_CONCURRENCY = 25

/** Package info needed for OSV queries */
interface PackageQueryInfo {
name: string
version: string
depth: DependencyDepth
path: string[]
}

/**
* Query OSV batch API to find which packages have vulnerabilities.
* Returns indices of packages that have vulnerabilities (for follow-up detailed queries).
* @see https://google.github.io/osv.dev/post-v1-querybatch/
*/
async function queryOsvBatch(
packages: PackageQueryInfo[],
): Promise<{ vulnerableIndices: number[]; failed: boolean }> {
if (packages.length === 0) return { vulnerableIndices: [], failed: false }

try {
const response = await $fetch<OsvBatchResponse>('https://api.osv.dev/v1/querybatch', {
method: 'POST',
body: {
queries: packages.map(pkg => ({
package: { name: pkg.name, ecosystem: 'npm' },
version: pkg.version,
})),
},
})

// Find indices of packages that have vulnerabilities
const vulnerableIndices: number[] = []
for (let i = 0; i < response.results.length; i++) {
const result = response.results[i]
if (result?.vulns && result.vulns.length > 0) {
vulnerableIndices.push(i)
}
// Warn if pagination token present (>1000 vulns for single query or >3000 total)
// This is extremely unlikely for npm packages but log for visibility
if (result?.next_page_token) {
// oxlint-disable-next-line no-console -- warn about paginated results
console.warn(
`[dep-analysis] OSV batch result has pagination token for package index ${i} ` +
`(${packages[i]?.name}@${packages[i]?.version}) - some vulnerabilities may be missing`,
)
}
}

return { vulnerableIndices, failed: false }
} catch (error) {
// oxlint-disable-next-line no-console -- log OSV API failures for debugging
console.warn(`[dep-analysis] OSV batch query failed:`, error)
return { vulnerableIndices: [], failed: true }
}
}

/**
* Query OSV for vulnerabilities in a package
* Query OSV for full vulnerability details for a single package.
* Only called for packages known to have vulnerabilities.
*/
async function queryOsv(
name: string,
version: string,
depth: DependencyDepth,
path: string[],
): Promise<OsvQueryResult> {
async function queryOsvDetails(pkg: PackageQueryInfo): Promise<PackageVulnerabilityInfo | null> {
try {
const response = await $fetch<OsvQueryResponse>('https://api.osv.dev/v1/query', {
method: 'POST',
body: {
package: { name, ecosystem: 'npm' },
version,
package: { name: pkg.name, ecosystem: 'npm' },
version: pkg.version,
},
})

const vulns = response.vulns || []
if (vulns.length === 0) return { status: 'ok', data: null }
if (vulns.length === 0) return null

const counts = { total: vulns.length, critical: 0, high: 0, moderate: 0, low: 0 }
const vulnerabilities: VulnerabilitySummary[] = []
Expand Down Expand Up @@ -65,11 +118,18 @@ async function queryOsv(
})
}

return { status: 'ok', data: { name, version, depth, path, vulnerabilities, counts } }
return {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It looks like the batch endpoint is also paginated.

It seems unlikely we'd ever have incomplete results in the first page:

  • An individual query within the queryset returns more than 1,000 vulnerabilities
  • The entire queryset returns more than 3,000 vulnerabilities total

but maybe just check for a non-nil next_page_token and log a warning/error for future visibility?

name: pkg.name,
version: pkg.version,
depth: pkg.depth,
path: pkg.path,
vulnerabilities,
counts,
}
} catch (error) {
// oxlint-disable-next-line no-console -- log OSV API failures for debugging
console.warn(`[dep-analysis] OSV query failed for ${name}@${version}:`, error)
return { status: 'error' }
console.warn(`[dep-analysis] OSV detail query failed for ${pkg.name}@${pkg.version}:`, error)
return null
}
}

Expand Down Expand Up @@ -110,17 +170,24 @@ function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel {

/**
* Analyze entire dependency tree for vulnerabilities and deprecated packages.
* Uses OSV batch API for efficient vulnerability discovery, then fetches
* full details only for packages with known vulnerabilities.
*/
export const analyzeDependencyTree = defineCachedFunction(
async (name: string, version: string): Promise<VulnerabilityTreeResult> => {
// Resolve all packages in the tree with depth tracking
const resolved = await resolveDependencyTree(name, version, { trackDepth: true })

// Convert to array for OSV querying
const packages = [...resolved.values()]
// Convert to array with query info
const packages: PackageQueryInfo[] = [...resolved.values()].map(pkg => ({
name: pkg.name,
version: pkg.version,
depth: pkg.depth!,
path: pkg.path || [],
}))

// Collect deprecated packages (no API call needed - already in packument data)
const deprecatedPackages: DeprecatedPackageInfo[] = packages
const deprecatedPackages: DeprecatedPackageInfo[] = [...resolved.values()]
.filter(pkg => pkg.deprecated)
.map(pkg => ({
name: pkg.name,
Expand All @@ -135,22 +202,27 @@ export const analyzeDependencyTree = defineCachedFunction(
return depthOrder[a.depth] - depthOrder[b.depth]
})

// Query OSV for all packages in parallel batches
const vulnerablePackages: PackageVulnerabilityInfo[] = []
let failedQueries = 0
const batchSize = 10
// Step 1: Use batch API to find which packages have vulnerabilities
// This is much faster than individual queries - one request for all packages
const { vulnerableIndices, failed: batchFailed } = await queryOsvBatch(packages)

let vulnerablePackages: PackageVulnerabilityInfo[] = []
let failedQueries = batchFailed ? packages.length : 0

for (let i = 0; i < packages.length; i += batchSize) {
const batch = packages.slice(i, i + batchSize)
const results = await Promise.all(
batch.map(pkg => queryOsv(pkg.name, pkg.version, pkg.depth!, pkg.path || [])),
if (!batchFailed && vulnerableIndices.length > 0) {
// Step 2: Fetch full vulnerability details only for packages with vulns
// This is typically a small fraction of total packages
const detailResults = await mapWithConcurrency(
vulnerableIndices,
i => queryOsvDetails(packages[i]!),
OSV_DETAIL_CONCURRENCY,
)

for (const result of results) {
if (result.status === 'error') {
for (const result of detailResults) {
if (result) {
vulnerablePackages.push(result)
} else {
failedQueries++
} else if (result.data) {
vulnerablePackages.push(result.data)
}
}
}
Expand All @@ -175,11 +247,11 @@ export const analyzeDependencyTree = defineCachedFunction(
totalCounts.low += pkg.counts.low
}

// Log critical failures (>50% of queries failed)
if (failedQueries > 0 && failedQueries > packages.length / 2) {
// Log if batch query failed entirely
if (batchFailed) {
// oxlint-disable-next-line no-console -- critical error logging
console.error(
`[dep-analysis] Critical: ${failedQueries}/${packages.length} OSV queries failed for ${name}@${version}`,
`[dep-analysis] Critical: OSV batch query failed for ${name}@${version} (${packages.length} packages)`,
)
}

Expand All @@ -197,6 +269,6 @@ export const analyzeDependencyTree = defineCachedFunction(
maxAge: 60 * 60,
swr: true,
name: 'dependency-analysis',
getKey: (name: string, version: string) => `v1:${name}@${version}`,
getKey: (name: string, version: string) => `v2:${name}@${version}`,
},
)
Loading
Loading