Skip to content

Commit 1a3f3bc

Browse files
authored
feat: add advisories pagination (CM-1283) (#4253)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent b11748e commit 1a3f3bc

5 files changed

Lines changed: 199 additions & 45 deletions

File tree

backend/src/api/public/v1/akrites/openapi.yaml

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -399,18 +399,22 @@ components:
399399

400400
Advisory:
401401
type: object
402-
required: [osvId, severity, resolution]
402+
required: [osvId, severity, resolution, isCritical]
403403
properties:
404404
osvId:
405405
type: string
406406
example: GHSA-xxxx-xxxx-xxxx
407407
severity:
408408
type: string
409-
enum: [critical, high, medium, low]
409+
enum: [critical, high, moderate, low]
410410
nullable: true
411411
resolution:
412412
type: string
413+
enum: [open, patched]
413414
nullable: true
415+
isCritical:
416+
type: boolean
417+
description: True when CVSS score >= 7.0.
414418

415419
PackageHistoryEvent:
416420
type: object
@@ -970,8 +974,9 @@ paths:
970974
operationId: getAkritesPackageAdvisories
971975
summary: Get advisories for a package
972976
description: >
973-
Returns all open security advisories for a single package identified by
974-
PURL. Intended for lazy-loading the Security tab in the package detail drawer.
977+
Returns a paginated list of security advisories for a single package
978+
identified by PURL. Intended for lazy-loading the Security tab in the
979+
package detail drawer.
975980
tags:
976981
- Packages
977982
parameters:
@@ -982,21 +987,66 @@ paths:
982987
schema:
983988
type: string
984989
example: pkg:npm/%40angular/core@17.0.0
990+
- name: page
991+
in: query
992+
required: false
993+
description: Page number (1-based).
994+
schema:
995+
type: integer
996+
minimum: 1
997+
default: 1
998+
- name: pageSize
999+
in: query
1000+
required: false
1001+
description: Number of advisories per page (max 100).
1002+
schema:
1003+
type: integer
1004+
minimum: 1
1005+
maximum: 100
1006+
default: 20
1007+
- name: severity
1008+
in: query
1009+
required: false
1010+
description: Filter by severity. Accepts comma-separated values or multiple params.
1011+
schema:
1012+
type: array
1013+
items:
1014+
type: string
1015+
enum: [critical, high, moderate, low]
1016+
- name: resolution
1017+
in: query
1018+
required: false
1019+
description: Filter by resolution status. Accepts comma-separated values or multiple params.
1020+
schema:
1021+
type: array
1022+
items:
1023+
type: string
1024+
enum: [open, patched]
1025+
- name: critical
1026+
in: query
1027+
required: false
1028+
description: Filter by criticality flag (CVSS >= 7.0).
1029+
schema:
1030+
type: boolean
9851031
responses:
9861032
'200':
987-
description: Advisory list.
1033+
description: Paginated advisory list.
9881034
content:
9891035
application/json:
9901036
schema:
9911037
type: object
992-
required: [advisories, total]
1038+
required: [page, pageSize, total, advisories]
9931039
properties:
1040+
page:
1041+
type: integer
1042+
pageSize:
1043+
type: integer
1044+
total:
1045+
type: integer
9941046
advisories:
9951047
type: array
9961048
items:
9971049
$ref: '#/components/schemas/Advisory'
998-
total:
999-
type: integer
10001050
'400':
10011051
description: Validation error (malformed purl).
10021052
content:

backend/src/api/public/v1/packages/getPackage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
3232
throw new NotFoundError()
3333
}
3434

35-
const [advisories, stewardshipSummary] = await Promise.all([
35+
const [{ rows: advisories }, stewardshipSummary] = await Promise.all([
3636
getAdvisoriesByPackageId(qx, pkg.id),
3737
pkg.stewardshipId ? getStewardshipSummary(qx, Number(pkg.stewardshipId)) : null,
3838
])
@@ -74,6 +74,7 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
7474
osvId: a.osvId,
7575
severity: a.severity,
7676
resolution: a.resolution,
77+
isCritical: a.isCritical,
7778
})),
7879
cvd: {
7980
isPvrEnabled: null,
Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
23

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

1011
import { purlQuerySchema } from './purl'
1112

13+
const DEFAULT_PAGE_SIZE = 20
14+
const MAX_PAGE_SIZE = 100
15+
16+
const SEVERITY_VALUES = ['critical', 'high', 'moderate', 'low'] as const
17+
const RESOLUTION_VALUES = ['open', 'patched'] as const
18+
19+
function toStringArray(v: unknown): unknown {
20+
if (!v) return undefined
21+
const vals = Array.isArray(v) ? v : [v]
22+
return vals
23+
.flatMap((s: unknown) => String(s).split(','))
24+
.map((s) => s.trim())
25+
.filter(Boolean)
26+
}
27+
28+
const querySchema = purlQuerySchema.extend({
29+
page: z.coerce.number().int().min(1).default(1),
30+
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
31+
severity: z.preprocess(toStringArray, z.array(z.enum(SEVERITY_VALUES)).optional()),
32+
resolution: z.preprocess(toStringArray, z.array(z.enum(RESOLUTION_VALUES)).optional()),
33+
critical: z
34+
.preprocess((v) => {
35+
if (v === 'true') return true
36+
if (v === 'false') return false
37+
return v
38+
}, z.boolean().optional())
39+
.optional(),
40+
})
41+
1242
export async function getPackageAdvisories(req: Request, res: Response): Promise<void> {
13-
const { purl } = validateOrThrow(purlQuerySchema, req.query)
43+
const { purl, page, pageSize, severity, resolution, critical } = validateOrThrow(
44+
querySchema,
45+
req.query,
46+
)
1447

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

22-
const advisories = await getAdvisoriesByPackageId(qx, pkg.id)
55+
const { rows, total } = await getAdvisoriesByPackageId(qx, pkg.id, {
56+
page,
57+
pageSize,
58+
severities: severity,
59+
resolutions: resolution,
60+
critical,
61+
})
2362

2463
ok(res, {
25-
advisories: advisories.map((a) => ({
64+
page,
65+
pageSize,
66+
total,
67+
advisories: rows.map((a) => ({
2668
osvId: a.osvId,
2769
severity: a.severity,
2870
resolution: a.resolution,
71+
isCritical: a.isCritical,
2972
})),
30-
total: advisories.length,
3173
})
3274
}

backend/src/api/public/v1/packages/purl.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,17 @@
1515
*/
1616
import { z } from 'zod'
1717

18+
function stripQualifiers(purl: string): string {
19+
const q = purl.indexOf('?')
20+
const h = purl.indexOf('#')
21+
if (q === -1 && h === -1) return purl
22+
if (q === -1) return purl.slice(0, h)
23+
if (h === -1) return purl.slice(0, q)
24+
return purl.slice(0, Math.min(q, h))
25+
}
26+
1827
export function normalizePurl(purl: string): string {
19-
const withoutQualifiers = purl.replace(/[?#].*$/, '')
28+
const withoutQualifiers = stripQualifiers(purl)
2029
const withoutVersion = withoutQualifiers.replace(/@[^/@]+$/, '')
2130
return withoutVersion.replace(/@/g, '%40')
2231
}

services/libs/data-access-layer/src/osspckgs/api.ts

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,7 @@ export interface AdvisoryRow {
624624
osvId: string
625625
severity: string
626626
resolution: 'open' | 'patched' | null
627+
isCritical: boolean
627628
}
628629

629630
export async function getPackageDetailByPurl(
@@ -783,35 +784,86 @@ export async function listPackagesForScatter(
783784
export async function getAdvisoriesByPackageId(
784785
qx: QueryExecutor,
785786
packageId: string,
786-
): Promise<AdvisoryRow[]> {
787-
return qx.select(
788-
`
789-
SELECT
790-
a.osv_id AS "osvId",
791-
LOWER(a.severity) AS severity,
792-
CASE
793-
WHEN p.latest_version IS NULL THEN NULL
794-
WHEN COUNT(ar.id) = 0 THEN NULL
795-
-- TODO: text comparison is lexicographic, not semver — '1.9.0' >= '1.10.0' is TRUE here.
796-
-- Replace with a proper semver comparison function when one is available in the DB.
797-
WHEN BOOL_AND(
798-
CASE
799-
WHEN ar.fixed_version IS NULL AND ar.last_affected IS NULL THEN FALSE
800-
WHEN ar.fixed_version IS NOT NULL AND p.latest_version >= ar.fixed_version THEN TRUE
801-
WHEN ar.fixed_version IS NOT NULL THEN FALSE
802-
WHEN ar.last_affected IS NOT NULL AND p.latest_version > ar.last_affected THEN TRUE
803-
ELSE FALSE
804-
END
805-
) THEN 'patched'
806-
ELSE 'open'
807-
END AS resolution
808-
FROM advisory_packages ap
809-
JOIN advisories a ON a.id = ap.advisory_id
810-
LEFT JOIN advisory_affected_ranges ar ON ar.advisory_package_id = ap.id
811-
JOIN packages p ON p.id = ap.package_id
812-
WHERE ap.package_id = $(packageId)::bigint
813-
GROUP BY a.osv_id, a.severity, p.latest_version
814-
`,
815-
{ packageId },
816-
)
787+
opts?: {
788+
page: number
789+
pageSize: number
790+
severities?: string[]
791+
resolutions?: ('open' | 'patched')[]
792+
critical?: boolean
793+
},
794+
): Promise<{ rows: AdvisoryRow[]; total: number }> {
795+
const cte = `
796+
WITH advisory_data AS (
797+
SELECT
798+
a.osv_id AS "osvId",
799+
LOWER(a.severity) AS severity,
800+
a.is_critical AS "isCritical",
801+
CASE
802+
WHEN p.latest_version IS NULL THEN NULL
803+
WHEN COUNT(ar.id) = 0 THEN NULL
804+
-- TODO: text comparison is lexicographic, not semver — '1.9.0' >= '1.10.0' is TRUE here.
805+
-- Replace with a proper semver comparison function when one is available in the DB.
806+
WHEN BOOL_AND(
807+
CASE
808+
WHEN ar.fixed_version IS NULL AND ar.last_affected IS NULL THEN FALSE
809+
WHEN ar.fixed_version IS NOT NULL AND p.latest_version >= ar.fixed_version THEN TRUE
810+
WHEN ar.fixed_version IS NOT NULL THEN FALSE
811+
WHEN ar.last_affected IS NOT NULL AND p.latest_version > ar.last_affected THEN TRUE
812+
ELSE FALSE
813+
END
814+
) THEN 'patched'
815+
ELSE 'open'
816+
END AS resolution
817+
FROM advisory_packages ap
818+
JOIN advisories a ON a.id = ap.advisory_id
819+
LEFT JOIN advisory_affected_ranges ar ON ar.advisory_package_id = ap.id
820+
JOIN packages p ON p.id = ap.package_id
821+
WHERE ap.package_id = $(packageId)::bigint
822+
GROUP BY a.osv_id, a.severity, a.is_critical, p.latest_version
823+
)
824+
`
825+
826+
const conditions: string[] = []
827+
if (opts?.severities?.length) {
828+
conditions.push('severity = ANY($(severities)::text[])')
829+
}
830+
if (opts?.resolutions?.length) {
831+
conditions.push('resolution = ANY($(resolutions)::text[])')
832+
}
833+
if (opts?.critical !== undefined) {
834+
conditions.push('"isCritical" = $(critical)')
835+
}
836+
837+
const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
838+
const paginationClause = opts ? `LIMIT $(limit) OFFSET $(offset)` : ''
839+
const params = {
840+
packageId,
841+
severities: opts?.severities ?? null,
842+
resolutions: opts?.resolutions ?? null,
843+
critical: opts?.critical ?? null,
844+
limit: opts?.pageSize,
845+
offset: opts ? (opts.page - 1) * opts.pageSize : 0,
846+
}
847+
848+
const rows = (await qx.select(
849+
`${cte} SELECT * FROM advisory_data
850+
${whereClause}
851+
ORDER BY
852+
CASE severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'moderate' THEN 3 WHEN 'low' THEN 4 ELSE 5 END,
853+
CASE resolution WHEN 'open' THEN 1 WHEN 'patched' THEN 2 ELSE 3 END,
854+
"osvId"
855+
${paginationClause}`,
856+
params,
857+
)) as AdvisoryRow[]
858+
859+
if (!opts) {
860+
return { rows, total: rows.length }
861+
}
862+
863+
const countResult = (await qx.selectOne(
864+
`${cte} SELECT COUNT(*) AS total FROM advisory_data ${whereClause}`,
865+
params,
866+
)) as { total: string }
867+
868+
return { rows, total: Number(countResult.total) }
817869
}

0 commit comments

Comments
 (0)