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
66 changes: 58 additions & 8 deletions backend/src/api/public/v1/akrites/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -399,18 +399,22 @@ components:

Advisory:
type: object
required: [osvId, severity, resolution]
required: [osvId, severity, resolution, isCritical]
properties:
osvId:
type: string
example: GHSA-xxxx-xxxx-xxxx
severity:
type: string
enum: [critical, high, medium, low]
enum: [critical, high, moderate, low]
Comment thread
ulemons marked this conversation as resolved.
nullable: true
resolution:
type: string
enum: [open, patched]
nullable: true
isCritical:
type: boolean
description: True when CVSS score >= 7.0.

PackageHistoryEvent:
type: object
Expand Down Expand Up @@ -970,8 +974,9 @@ paths:
operationId: getAkritesPackageAdvisories
summary: Get advisories for a package
description: >
Returns all open security advisories for a single package identified by
PURL. Intended for lazy-loading the Security tab in the package detail drawer.
Returns a paginated list of security advisories for a single package
identified by PURL. Intended for lazy-loading the Security tab in the
package detail drawer.
tags:
- Packages
parameters:
Expand All @@ -982,21 +987,66 @@ paths:
schema:
type: string
example: pkg:npm/%40angular/core@17.0.0
- name: page
in: query
required: false
description: Page number (1-based).
schema:
type: integer
minimum: 1
default: 1
- name: pageSize
in: query
required: false
description: Number of advisories per page (max 100).
schema:
type: integer
minimum: 1
maximum: 100
default: 20
- name: severity
in: query
required: false
description: Filter by severity. Accepts comma-separated values or multiple params.
schema:
type: array
items:
type: string
enum: [critical, high, moderate, low]
Comment thread
ulemons marked this conversation as resolved.
- name: resolution
in: query
required: false
description: Filter by resolution status. Accepts comma-separated values or multiple params.
schema:
type: array
items:
type: string
enum: [open, patched]
- name: critical
in: query
required: false
description: Filter by criticality flag (CVSS >= 7.0).
schema:
type: boolean
responses:
'200':
description: Advisory list.
description: Paginated advisory list.
content:
application/json:
schema:
type: object
required: [advisories, total]
required: [page, pageSize, total, advisories]
properties:
page:
type: integer
pageSize:
type: integer
total:
type: integer
advisories:
type: array
items:
$ref: '#/components/schemas/Advisory'
total:
type: integer
'400':
description: Validation error (malformed purl).
content:
Expand Down
3 changes: 2 additions & 1 deletion backend/src/api/public/v1/packages/getPackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
throw new NotFoundError()
}

const [advisories, stewardshipSummary] = await Promise.all([
const [{ rows: advisories }, stewardshipSummary] = await Promise.all([
getAdvisoriesByPackageId(qx, pkg.id),
pkg.stewardshipId ? getStewardshipSummary(qx, Number(pkg.stewardshipId)) : null,
])
Expand Down Expand Up @@ -74,6 +74,7 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
osvId: a.osvId,
severity: a.severity,
resolution: a.resolution,
isCritical: a.isCritical,
})),
cvd: {
isPvrEnabled: null,
Expand Down
50 changes: 46 additions & 4 deletions backend/src/api/public/v1/packages/getPackageAdvisories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { NotFoundError } from '@crowd/common'
import { getAdvisoriesByPackageId, getPackageDetailByPurl } from '@crowd/data-access-layer'
Expand All @@ -9,8 +10,40 @@ import { validateOrThrow } from '@/utils/validation'

import { purlQuerySchema } from './purl'

const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100

const SEVERITY_VALUES = ['critical', 'high', 'moderate', 'low'] as const
Comment thread
ulemons marked this conversation as resolved.
Comment thread
ulemons marked this conversation as resolved.
const RESOLUTION_VALUES = ['open', 'patched'] as const

function toStringArray(v: unknown): unknown {
if (!v) return undefined
const vals = Array.isArray(v) ? v : [v]
return vals
.flatMap((s: unknown) => String(s).split(','))
.map((s) => s.trim())
.filter(Boolean)
}

const querySchema = purlQuerySchema.extend({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
severity: z.preprocess(toStringArray, z.array(z.enum(SEVERITY_VALUES)).optional()),
resolution: z.preprocess(toStringArray, z.array(z.enum(RESOLUTION_VALUES)).optional()),
critical: z
.preprocess((v) => {
if (v === 'true') return true
if (v === 'false') return false
return v
}, z.boolean().optional())
.optional(),
})

export async function getPackageAdvisories(req: Request, res: Response): Promise<void> {
const { purl } = validateOrThrow(purlQuerySchema, req.query)
const { purl, page, pageSize, severity, resolution, critical } = validateOrThrow(
querySchema,
req.query,
)

const qx = await getPackagesQx()
const pkg = await getPackageDetailByPurl(qx, purl)
Expand All @@ -19,14 +52,23 @@ export async function getPackageAdvisories(req: Request, res: Response): Promise
throw new NotFoundError()
}

const advisories = await getAdvisoriesByPackageId(qx, pkg.id)
const { rows, total } = await getAdvisoriesByPackageId(qx, pkg.id, {
page,
pageSize,
severities: severity,
resolutions: resolution,
critical,
})

ok(res, {
advisories: advisories.map((a) => ({
page,
pageSize,
total,
advisories: rows.map((a) => ({
osvId: a.osvId,
severity: a.severity,
resolution: a.resolution,
isCritical: a.isCritical,
})),
total: advisories.length,
})
}
11 changes: 10 additions & 1 deletion backend/src/api/public/v1/packages/purl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@
*/
import { z } from 'zod'

function stripQualifiers(purl: string): string {
const q = purl.indexOf('?')
const h = purl.indexOf('#')
if (q === -1 && h === -1) return purl
if (q === -1) return purl.slice(0, h)
if (h === -1) return purl.slice(0, q)
return purl.slice(0, Math.min(q, h))
}

export function normalizePurl(purl: string): string {
const withoutQualifiers = purl.replace(/[?#].*$/, '')
const withoutQualifiers = stripQualifiers(purl)
const withoutVersion = withoutQualifiers.replace(/@[^/@]+$/, '')
return withoutVersion.replace(/@/g, '%40')
}
Expand Down
114 changes: 83 additions & 31 deletions services/libs/data-access-layer/src/osspckgs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,7 @@ export interface AdvisoryRow {
osvId: string
severity: string
resolution: 'open' | 'patched' | null
isCritical: boolean
}

export async function getPackageDetailByPurl(
Expand Down Expand Up @@ -783,35 +784,86 @@ export async function listPackagesForScatter(
export async function getAdvisoriesByPackageId(
qx: QueryExecutor,
packageId: string,
): Promise<AdvisoryRow[]> {
return qx.select(
`
SELECT
a.osv_id AS "osvId",
LOWER(a.severity) AS severity,
CASE
WHEN p.latest_version IS NULL THEN NULL
WHEN COUNT(ar.id) = 0 THEN NULL
-- TODO: text comparison is lexicographic, not semver — '1.9.0' >= '1.10.0' is TRUE here.
-- Replace with a proper semver comparison function when one is available in the DB.
WHEN BOOL_AND(
CASE
WHEN ar.fixed_version IS NULL AND ar.last_affected IS NULL THEN FALSE
WHEN ar.fixed_version IS NOT NULL AND p.latest_version >= ar.fixed_version THEN TRUE
WHEN ar.fixed_version IS NOT NULL THEN FALSE
WHEN ar.last_affected IS NOT NULL AND p.latest_version > ar.last_affected THEN TRUE
ELSE FALSE
END
) THEN 'patched'
ELSE 'open'
END AS resolution
FROM advisory_packages ap
JOIN advisories a ON a.id = ap.advisory_id
LEFT JOIN advisory_affected_ranges ar ON ar.advisory_package_id = ap.id
JOIN packages p ON p.id = ap.package_id
WHERE ap.package_id = $(packageId)::bigint
GROUP BY a.osv_id, a.severity, p.latest_version
`,
{ packageId },
)
opts?: {
page: number
pageSize: number
severities?: string[]
resolutions?: ('open' | 'patched')[]
critical?: boolean
},
): Promise<{ rows: AdvisoryRow[]; total: number }> {
const cte = `
WITH advisory_data AS (
SELECT
a.osv_id AS "osvId",
LOWER(a.severity) AS severity,
a.is_critical AS "isCritical",
Comment thread
ulemons marked this conversation as resolved.
Comment thread
ulemons marked this conversation as resolved.
CASE
WHEN p.latest_version IS NULL THEN NULL
WHEN COUNT(ar.id) = 0 THEN NULL
-- TODO: text comparison is lexicographic, not semver — '1.9.0' >= '1.10.0' is TRUE here.
-- Replace with a proper semver comparison function when one is available in the DB.
WHEN BOOL_AND(
CASE
WHEN ar.fixed_version IS NULL AND ar.last_affected IS NULL THEN FALSE
WHEN ar.fixed_version IS NOT NULL AND p.latest_version >= ar.fixed_version THEN TRUE
WHEN ar.fixed_version IS NOT NULL THEN FALSE
WHEN ar.last_affected IS NOT NULL AND p.latest_version > ar.last_affected THEN TRUE
ELSE FALSE
END
) THEN 'patched'
ELSE 'open'
END AS resolution
FROM advisory_packages ap
JOIN advisories a ON a.id = ap.advisory_id
LEFT JOIN advisory_affected_ranges ar ON ar.advisory_package_id = ap.id
JOIN packages p ON p.id = ap.package_id
WHERE ap.package_id = $(packageId)::bigint
GROUP BY a.osv_id, a.severity, a.is_critical, p.latest_version
)
`

const conditions: string[] = []
if (opts?.severities?.length) {
conditions.push('severity = ANY($(severities)::text[])')
}
if (opts?.resolutions?.length) {
conditions.push('resolution = ANY($(resolutions)::text[])')
}
if (opts?.critical !== undefined) {
conditions.push('"isCritical" = $(critical)')
}

const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const paginationClause = opts ? `LIMIT $(limit) OFFSET $(offset)` : ''
const params = {
packageId,
severities: opts?.severities ?? null,
resolutions: opts?.resolutions ?? null,
critical: opts?.critical ?? null,
limit: opts?.pageSize,
offset: opts ? (opts.page - 1) * opts.pageSize : 0,
}

const rows = (await qx.select(
`${cte} SELECT * FROM advisory_data
${whereClause}
ORDER BY
CASE severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'moderate' THEN 3 WHEN 'low' THEN 4 ELSE 5 END,
Comment thread
ulemons marked this conversation as resolved.
Comment thread
ulemons marked this conversation as resolved.
Comment thread
ulemons marked this conversation as resolved.
CASE resolution WHEN 'open' THEN 1 WHEN 'patched' THEN 2 ELSE 3 END,
"osvId"
${paginationClause}`,
params,
)) as AdvisoryRow[]

if (!opts) {
return { rows, total: rows.length }
}

const countResult = (await qx.selectOne(
`${cte} SELECT COUNT(*) AS total FROM advisory_data ${whereClause}`,
params,
)) as { total: string }
Comment thread
ulemons marked this conversation as resolved.

return { rows, total: Number(countResult.total) }
}
Loading