diff --git a/backend/src/api/public/v1/akrites/openapi.yaml b/backend/src/api/public/v1/akrites/openapi.yaml index e202292b7e..e56ce2128c 100644 --- a/backend/src/api/public/v1/akrites/openapi.yaml +++ b/backend/src/api/public/v1/akrites/openapi.yaml @@ -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] nullable: true resolution: type: string + enum: [open, patched] nullable: true + isCritical: + type: boolean + description: True when CVSS score >= 7.0. PackageHistoryEvent: type: object @@ -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: @@ -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] + - 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: diff --git a/backend/src/api/public/v1/packages/getPackage.ts b/backend/src/api/public/v1/packages/getPackage.ts index 75f6f50fab..8c4414e170 100644 --- a/backend/src/api/public/v1/packages/getPackage.ts +++ b/backend/src/api/public/v1/packages/getPackage.ts @@ -32,7 +32,7 @@ export async function getPackage(req: Request, res: Response): Promise { 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, ]) @@ -74,6 +74,7 @@ export async function getPackage(req: Request, res: Response): Promise { osvId: a.osvId, severity: a.severity, resolution: a.resolution, + isCritical: a.isCritical, })), cvd: { isPvrEnabled: null, diff --git a/backend/src/api/public/v1/packages/getPackageAdvisories.ts b/backend/src/api/public/v1/packages/getPackageAdvisories.ts index f5a8a55965..615836151b 100644 --- a/backend/src/api/public/v1/packages/getPackageAdvisories.ts +++ b/backend/src/api/public/v1/packages/getPackageAdvisories.ts @@ -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' @@ -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 +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 { - 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) @@ -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, }) } diff --git a/backend/src/api/public/v1/packages/purl.ts b/backend/src/api/public/v1/packages/purl.ts index 7a043a9ed1..cd3e497753 100644 --- a/backend/src/api/public/v1/packages/purl.ts +++ b/backend/src/api/public/v1/packages/purl.ts @@ -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') } diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 901a6a47ce..a1d3fa7a60 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -624,6 +624,7 @@ export interface AdvisoryRow { osvId: string severity: string resolution: 'open' | 'patched' | null + isCritical: boolean } export async function getPackageDetailByPurl( @@ -783,35 +784,86 @@ export async function listPackagesForScatter( export async function getAdvisoriesByPackageId( qx: QueryExecutor, packageId: string, -): Promise { - 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", + 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, + 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 } + + return { rows, total: Number(countResult.total) } }