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
19 changes: 12 additions & 7 deletions backend/src/api/public/v1/akrites-external/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -421,9 +421,11 @@ components:
nullable: true
description: >
Optional. Full purl or bare package name. If omitted, the analysis
is advisory-wide: reachability is evaluated across every package
the advisory affects, returned as one aggregate result — not one
job per affected package.
is advisory-wide: for advisories affecting a single package, that
package is analyzed. Advisories affecting multiple packages
require an explicit package — the job fails (status: 'failed')
otherwise, since analyzing every affected package in one
aggregate result is not yet supported.
force:
type: boolean
default: false
Expand Down Expand Up @@ -1238,8 +1240,10 @@ paths:
summary: 2a — Submit a blast-radius analysis job
description: >
Always exactly one job per request — no bulk submit. Omit package for
an advisory-wide analysis; provide it to narrow to a single package.
Starts a Temporal workflow running the 4-stage reachability pipeline
an advisory-wide analysis (only supported for single-package
advisories); provide it to narrow to one package, which is required
for advisories affecting more than one. Starts a Temporal workflow
running the 4-stage reachability pipeline
(intel, dependents, reachability, report) for npm, go, or maven; other
ecosystems fail fast with ECOSYSTEM_NOT_SUPPORTED. Poll status/results
via GET /jobs/{analysisId}.
Expand Down Expand Up @@ -1300,8 +1304,9 @@ paths:
summary: 2a bulk — Submit multiple blast-radius analysis jobs
description: >
One job per array entry, same semantics as the single-job submit —
omit package for an advisory-wide analysis, provide it to narrow to a
single package. Each entry starts its own Temporal workflow, so the
omit package for an advisory-wide analysis (only supported for
single-package advisories), provide it to narrow to a single package.
Each entry starts its own Temporal workflow, so the
batch is capped at 20 jobs (10 recommended as the default batch
size), much lower than the 100-item read batches, and stays behind
the same strict rate limiter as the single-job route.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'

import { type OsvVuln, affectedEntriesForEcosystem, semverRangeEvents } from '../osvClient'

function vuln(affected: OsvVuln['affected']): OsvVuln {
return { id: 'GHSA-1', aliases: [], references: [], affected }
}

describe('affectedEntriesForEcosystem', () => {
it('merges duplicate same-package entries into one, aggregating all ranges', () => {
const osv = vuln([
{
package: { ecosystem: 'npm', name: 'pkg-a' },
ranges: [{ type: 'SEMVER', events: [{ introduced: '1.0.0' }, { fixed: '1.2.0' }] }],
},
{
package: { ecosystem: 'npm', name: 'pkg-a' },
ranges: [{ type: 'SEMVER', events: [{ introduced: '2.0.0' }, { fixed: '2.5.0' }] }],
},
])

const entries = affectedEntriesForEcosystem(osv, 'npm')

expect(entries).toHaveLength(1)
expect(semverRangeEvents(entries[0])).toEqual([
{ introduced: '1.0.0', fixed: '1.2.0', lastAffected: null },
{ introduced: '2.0.0', fixed: '2.5.0', lastAffected: null },
])
})

it('keeps distinct packages separate', () => {
const osv = vuln([
{ package: { ecosystem: 'npm', name: 'pkg-a' } },
{ package: { ecosystem: 'npm', name: 'pkg-b' } },
])

const entries = affectedEntriesForEcosystem(osv, 'npm')

expect(entries.map((e) => e.package.name)).toEqual(['pkg-a', 'pkg-b'])
})

it('dedups overlapping exact versions across duplicate entries', () => {
const osv = vuln([
{ package: { ecosystem: 'npm', name: 'pkg-a' }, versions: ['1.0.0', '1.0.1'] },
{ package: { ecosystem: 'npm', name: 'pkg-a' }, versions: ['1.0.1', '1.0.2'] },
])

const entries = affectedEntriesForEcosystem(osv, 'npm')

expect(entries).toHaveLength(1)
expect(entries[0].versions).toEqual(['1.0.0', '1.0.1', '1.0.2'])
})

it('drops exact-duplicate range tuples instead of storing them twice', () => {
const range = { type: 'SEMVER', events: [{ introduced: '1.0.0' }, { fixed: '1.2.0' }] }
const osv = vuln([
{ package: { ecosystem: 'npm', name: 'pkg-a' }, ranges: [range] },
{ package: { ecosystem: 'npm', name: 'pkg-a' }, ranges: [range] },
])

const entries = affectedEntriesForEcosystem(osv, 'npm')

expect(entries).toHaveLength(1)
expect(entries[0].ranges).toEqual([range])
})

it('keeps same-named packages from different ecosystems separate', () => {
const osv = vuln([
{ package: { ecosystem: 'npm', name: 'path' } },
{ package: { ecosystem: 'Go', name: 'path' } },
])

expect(affectedEntriesForEcosystem(osv, 'npm')).toHaveLength(1)
expect(affectedEntriesForEcosystem(osv, 'Go')).toHaveLength(1)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,37 @@ export function affectedEntriesForEcosystem(
ecosystem: string,
): OsvAffectedPackage[] {
if (!vuln.affected) return []
return vuln.affected.filter((a) => a.package?.ecosystem === ecosystem)
const filtered = vuln.affected.filter((a) => a.package?.ecosystem === ecosystem)
return mergeDuplicatePackageEntries(filtered)
}

// OSV can legitimately list the same package multiple times to represent disjoint
// vulnerable-version ranges — merge duplicates so callers see every range for a package.
function mergeDuplicatePackageEntries(entries: OsvAffectedPackage[]): OsvAffectedPackage[] {
const merged = new Map<string, OsvAffectedPackage>()

for (const entry of entries) {
const key = `${entry.package.ecosystem}|${entry.package.name}`
const existing = merged.get(key)
if (!existing) {
merged.set(key, {
package: entry.package,
ranges: entry.ranges ? [...entry.ranges] : undefined,
versions: entry.versions ? [...entry.versions] : undefined,
})
continue
}
if (entry.ranges) {
const seen = new Set((existing.ranges ?? []).map((r) => JSON.stringify(r)))
const newRanges = entry.ranges.filter((r) => !seen.has(JSON.stringify(r)))
existing.ranges = [...(existing.ranges ?? []), ...newRanges]
}
if (entry.versions) {
existing.versions = [...new Set([...(existing.versions ?? []), ...entry.versions])]
}
}

return [...merged.values()]
}

// Extract npm-specific affected packages from an OSV record.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'

import { selectAdvisoryEntry } from '../selectAdvisoryEntry'

interface FakeEntry {
package: { name: string }
}

function entry(name: string): FakeEntry {
return { package: { name } }
}

describe('selectAdvisoryEntry', () => {
it('returns the single entry when no package was requested', () => {
const entries = [entry('pkg-a')]
const result = selectAdvisoryEntry(entries, null, (e) => e.package.name === 'pkg-a', 'GHSA-1')
expect(result.entry).toBe(entries[0])
expect(result.relatedAffectedPackages).toEqual([])
})

it('returns the matching entry when the requested package is in the advisory', () => {
const entries = [entry('pkg-a'), entry('pkg-b')]
const result = selectAdvisoryEntry(
entries,
'pkg-b',
(e) => e.package.name === 'pkg-b',
'GHSA-1',
)
expect(result.entry).toBe(entries[1])
expect(result.relatedAffectedPackages).toEqual(['pkg-a'])
})

it('rejects a requested package that is not in the advisory instead of falling back', () => {
const entries = [entry('pkg-a'), entry('pkg-b')]
expect(() =>
selectAdvisoryEntry(entries, 'pkg-c', (e) => e.package.name === 'pkg-c', 'GHSA-1'),
).toThrow(/pkg-c.*not found in advisory GHSA-1.*pkg-a, pkg-b/)
})

it('rejects an omitted package against a multi-artifact advisory instead of picking the first entry', () => {
const entries = [entry('pkg-a'), entry('pkg-b')]
expect(() => selectAdvisoryEntry(entries, null, () => false, 'GHSA-1')).toThrow(
/GHSA-1 affects 2 packages \(pkg-a, pkg-b\)/,
)
})

it('treats an empty-string request as explicit, not as "no request"', () => {
const entries = [entry('pkg-a'), entry('pkg-b')]
expect(() => selectAdvisoryEntry(entries, '', () => false, 'GHSA-1')).toThrow(
/Requested package {2}not found in advisory GHSA-1.*pkg-a, pkg-b/,
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '../../clients/osvClient'
import { toBareGoModule } from '../../packageIdentifier'
import { highestVersion, versionsInRanges } from '../../semverRange'
import { selectAdvisoryEntry } from '../selectAdvisoryEntry'

// OSV spells the Go ecosystem 'Go' (capital), unlike our DB's lowercase 'go' — see
// ADR-0001 §OSV "Ecosystem normalization" for the DB-side convention.
Expand Down Expand Up @@ -57,19 +58,20 @@ export async function runIntelStageGo(
throw new Error(`No Go entries found in advisory ${advisoryOsvId}`)
}

// Multi-module advisories list one Go entry per affected module — pick the one the
// analysis was actually requested for, falling back to the first entry otherwise.
// Pick the Go entry the analysis was requested for; see selectAdvisoryEntry for
// rejection rules on non-matching or omitted requests.
const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId)
const requestedModule = analysisDetail?.package_name
? toBareGoModule(analysisDetail.package_name)
: null
const entry =
(requestedModule && goEntries.find((e) => e.package.name === requestedModule)) || goEntries[0]
const { entry, relatedAffectedPackages } = selectAdvisoryEntry(
goEntries,
requestedModule,
(e) => e.package.name === requestedModule,
advisoryOsvId,
)
const module_ = entry.package.name
const ecosystem = 'go'
const relatedAffectedPackages = goEntries
.map((e) => e.package.name)
.filter((name) => name !== module_)

// Resolve vulnerable versions from OSV ranges first (Go OSV ranges are SEMVER-typed,
// same event shape as npm's).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
fixReferenceUrls,
} from '../../clients/osvClient'
import { toBareMavenCoordinate } from '../../packageIdentifier'
import { selectAdvisoryEntry } from '../selectAdvisoryEntry'

import { highestVersion, mavenRangeEvents, versionsInRanges } from './mavenVersions'

Expand Down Expand Up @@ -61,26 +62,26 @@ export async function runIntelStageMaven(
throw new Error(`No Maven entries found in advisory ${advisoryOsvId}`)
}

// Multi-artifact advisories list one Maven entry per affected artifact — pick the one
// the analysis was actually requested for, falling back to the first entry otherwise.
// Pick the Maven entry the analysis was requested for; see selectAdvisoryEntry for
// rejection rules on non-matching or omitted requests.
const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId)
const requested = analysisDetail?.package_name
? toBareMavenCoordinate(analysisDetail.package_name)
: null
const entry =
(requested &&
mavenEntries.find((e) => {
const coord = toBareMavenCoordinate(e.package.name)
return coord.groupId === requested.groupId && coord.artifactId === requested.artifactId
})) ||
mavenEntries[0]

const { groupId, artifactId } = toBareMavenCoordinate(entry.package.name)
const requestedCoordinate = requested ? `${requested.groupId}:${requested.artifactId}` : null
const { entry, relatedAffectedPackages } = selectAdvisoryEntry(
mavenEntries,
requestedCoordinate,
(e) => {
const coord = toBareMavenCoordinate(e.package.name)
return `${coord.groupId}:${coord.artifactId}` === requestedCoordinate
},
advisoryOsvId,
)

const { groupId, artifactId } = requested ?? toBareMavenCoordinate(entry.package.name)
const coordinate = `${groupId}:${artifactId}`
const ecosystem = 'maven'
const relatedAffectedPackages = mavenEntries
.map((e) => e.package.name)
.filter((name) => name !== entry.package.name)

// Resolve vulnerable versions from OSV ranges first (Maven OSV ranges are
// ECOSYSTEM-typed, not SEMVER — Maven versions don't follow semver ordering).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { asNpmVersionManifest } from '../../npmManifest'
import { toBareNpmName } from '../../packageIdentifier'
import { highestVersion, versionsInRanges } from '../../semverRange'
import { selectAdvisoryEntry } from '../selectAdvisoryEntry'

export async function runIntelStageNpm(
qx: QueryExecutor,
Expand Down Expand Up @@ -56,23 +57,20 @@ export async function runIntelStageNpm(
throw new Error(`No npm entries found in advisory ${advisoryOsvId}`)
}

// Multi-package advisories list one npm entry per affected package — pick the one
// the analysis was actually requested for, falling back to the first entry when no
// specific package was requested (analysis-wide advisory scan). The request accepts
// either a bare name or a full purl (see blastRadiusJobRequestSchema), but OSV entries
// are always bare names, so the requested package must be normalized before comparing.
// Requested package may be a bare name or full purl; OSV entries are always bare
// names, so normalize before comparing (see selectAdvisoryEntry for rejection rules).
const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId)
const requestedPackage = analysisDetail?.package_name
? toBareNpmName(analysisDetail.package_name)
: null
const entry =
(requestedPackage && npmEntries.find((e) => e.package.name === requestedPackage)) ||
npmEntries[0]
const { entry, relatedAffectedPackages } = selectAdvisoryEntry(
npmEntries,
requestedPackage,
(e) => e.package.name === requestedPackage,
advisoryOsvId,
)
const package_ = entry.package.name
const ecosystem = entry.package.ecosystem
const relatedAffectedPackages = npmEntries
.map((e) => e.package.name)
.filter((name) => name !== package_)

// Fetch the registry packument so vulnerable-version resolution runs against versions
// npm actually published, not just the OSV range's introduced/fixed boundary strings —
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ApplicationFailure } from '@temporalio/activity'

// Rejects loudly instead of silently falling back to entries[0], which used to analyze
// the wrong (or only one of several) artifact while reporting the analysis as completed.
export interface SelectedAdvisoryEntry<T> {
entry: T
relatedAffectedPackages: string[]
}

export function selectAdvisoryEntry<T extends { package: { name: string } }>(
entries: T[],
requestedPackageName: string | null,
matchesRequested: (entry: T) => boolean,
advisoryOsvId: string,
): SelectedAdvisoryEntry<T> {
const affectedNames = entries.map((e) => e.package.name)

// `!== null`, not truthiness — an empty string is still an explicit (if malformed)
// request and must go through matching/rejection, not be treated as "none requested".
if (requestedPackageName !== null) {
const entry = entries.find(matchesRequested)
Comment thread
ulemons marked this conversation as resolved.
if (!entry) {
throw ApplicationFailure.nonRetryable(
`Requested package ${requestedPackageName} not found in advisory ${advisoryOsvId} ` +
`(affected: ${affectedNames.join(', ')})`,
'ADVISORY_PACKAGE_NOT_FOUND',
)
}
return {
entry,
relatedAffectedPackages: affectedNames.filter((name) => name !== entry.package.name),
}
}

if (entries.length > 1) {
throw ApplicationFailure.nonRetryable(
`Advisory ${advisoryOsvId} affects ${entries.length} packages (${affectedNames.join(', ')}); ` +
`advisory-wide analysis is not supported for multi-artifact advisories — specify one via 'package'`,
'ADVISORY_MULTI_ARTIFACT_AMBIGUOUS',
)
}

return { entry: entries[0], relatedAffectedPackages: [] }
}
Loading