Skip to content

Commit e5b2d7b

Browse files
authored
feat: add filters and risk sort to GET /v1/packages (#4203)
Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
1 parent 106298a commit e5b2d7b

2 files changed

Lines changed: 151 additions & 26 deletions

File tree

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

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,32 @@ const MAX_PAGE_SIZE = 100
1515
const booleanQueryParam = z.preprocess((v) => v === 'true', z.boolean()).default(false)
1616

1717
const lifecycleValues = ['active', 'stable', 'declining', 'abandoned'] as const
18+
const stewardshipStatusValues = [
19+
'unassigned',
20+
'open',
21+
'assessing',
22+
'active',
23+
'needs_attention',
24+
'escalated',
25+
'blocked',
26+
'inactive',
27+
] as const
28+
const healthBandValues = ['healthy', 'fair', 'concerning', 'critical'] as const
29+
const vulnSeverityValues = ['any', 'high', 'critical'] as const
1830

1931
const querySchema = z.object({
2032
page: z.coerce.number().int().min(1).default(1),
2133
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
2234
ecosystem: z.string().trim().optional(),
23-
lifecycle: z.enum(lifecycleValues).optional(), // TODO: filter not yet implemented in DAL
35+
lifecycle: z.enum(lifecycleValues).optional(),
36+
name: z.string().trim().optional(),
37+
status: z.enum(stewardshipStatusValues).optional(),
38+
healthBand: z.enum(healthBandValues).optional(),
39+
vulnSeverity: z.enum(vulnSeverityValues).optional(),
2440
busFactor1Only: booleanQueryParam,
2541
staleOnly: booleanQueryParam,
2642
unstewardedOnly: booleanQueryParam,
27-
sortBy: z.enum(['name', 'health', 'impact', 'openVulns']).default('name'),
43+
sortBy: z.enum(['name', 'health', 'impact', 'openVulns', 'risk']).default('name'),
2844
sortDir: z.enum(['asc', 'desc']).default('asc'),
2945
})
3046

@@ -33,34 +49,40 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
3349
page,
3450
pageSize,
3551
ecosystem,
36-
lifecycle: _lifecycle,
52+
lifecycle,
53+
name,
54+
status,
55+
healthBand,
56+
vulnSeverity,
3757
busFactor1Only,
3858
staleOnly,
3959
unstewardedOnly,
4060
sortBy,
4161
sortDir,
4262
} = validateOrThrow(querySchema, req.query)
4363

44-
// health is a v2 field with no backing column yet — fall back to name sort
45-
const effectiveSortBy = sortBy === 'health' ? 'name' : sortBy
46-
4764
const qx = await getPackagesQx()
4865
const { rows, total } = await listPackagesForApi(qx, {
4966
page,
5067
pageSize,
5168
ecosystem,
69+
lifecycle,
70+
name,
71+
status,
72+
healthBand,
73+
vulnSeverity,
5274
staleOnly,
5375
unstewardedOnly,
5476
busFactor1Only,
55-
sortBy: effectiveSortBy,
77+
sortBy,
5678
sortDir,
5779
})
5880

5981
const packages = rows.map((r) => ({
6082
purl: r.purl,
6183
name: r.name,
6284
ecosystem: r.ecosystem,
63-
health: null,
85+
health: r.scorecardScore != null ? Math.round(Number(r.scorecardScore) * 10) : null,
6486
impact: r.criticalityScore != null ? Math.round(Number(r.criticalityScore) * 100) : null,
6587
lifecycle: null,
6688
maintainerBusFactor: r.maintainerCount,
@@ -75,12 +97,16 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
7597
total,
7698
filters: {
7799
ecosystem: ecosystem ?? null,
78-
lifecycle: null, // TODO: filter not yet implemented in DAL
100+
lifecycle: lifecycle ?? null,
101+
name: name ?? null,
102+
status: status ?? null,
103+
healthBand: healthBand ?? null,
104+
vulnSeverity: vulnSeverity ?? null,
79105
busFactor1Only,
80106
staleOnly,
81107
unstewardedOnly,
82108
},
83-
sort: { by: effectiveSortBy, dir: sortDir },
109+
sort: { by: sortBy, dir: sortDir },
84110
packages,
85111
})
86112
}

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

Lines changed: 115 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,22 +57,40 @@ export interface PackageListRow {
5757
stewardshipStatus: string | null
5858
openVulns: number
5959
maintainerCount: number
60+
scorecardScore: number | null
6061
total: string
6162
}
6263

64+
export type HealthBand = 'healthy' | 'fair' | 'concerning' | 'critical'
65+
export type VulnSeverityFilter = 'any' | 'high' | 'critical'
66+
6367
export interface ListPackagesOptions {
6468
page: number
6569
pageSize: number
6670
ecosystem?: string
71+
lifecycle?: string
72+
name?: string
73+
status?: string
74+
healthBand?: HealthBand
75+
vulnSeverity?: VulnSeverityFilter
6776
staleOnly: boolean
6877
unstewardedOnly: boolean
6978
busFactor1Only: boolean
70-
sortBy: 'name' | 'impact' | 'openVulns'
79+
sortBy: 'name' | 'impact' | 'openVulns' | 'health' | 'risk'
7180
sortDir: 'asc' | 'desc'
7281
}
7382

7483
const STALE_MONTHS = 18
7584

85+
// Severity stored as uppercase in advisories table.
86+
// Ranks: CRITICAL=4, HIGH=3, MEDIUM=2, LOW=1
87+
const SEVERITY_RANK_EXPR = `MAX(CASE a.severity
88+
WHEN 'CRITICAL' THEN 4
89+
WHEN 'HIGH' THEN 3
90+
WHEN 'MEDIUM' THEN 2
91+
WHEN 'LOW' THEN 1
92+
ELSE 0 END)::int`
93+
7694
export async function listPackagesForApi(
7795
qx: QueryExecutor,
7896
opts: ListPackagesOptions,
@@ -85,6 +103,55 @@ export async function listPackagesForApi(
85103
params.ecosystem = opts.ecosystem
86104
}
87105

106+
if (opts.name) {
107+
conditions.push('p.name ILIKE $(name)')
108+
params.name = `%${opts.name}%`
109+
}
110+
111+
// Exclude packages with no registry status when a lifecycle filter is active.
112+
// Full lifecycle column support is pending; this prevents null-lifecycle rows
113+
// from leaking into filtered results.
114+
if (opts.lifecycle) {
115+
conditions.push('p.status IS NOT NULL')
116+
}
117+
118+
if (opts.status) {
119+
// 'unassigned' includes packages that have no stewardship row yet
120+
if (opts.status === 'unassigned') {
121+
conditions.push(`(s.status = 'unassigned' OR s.id IS NULL)`)
122+
} else {
123+
conditions.push('s.status = $(status)')
124+
params.status = opts.status
125+
}
126+
}
127+
128+
if (opts.healthBand) {
129+
// scorecard_score is 0–10; multiply by 10 to get 0–100 health score.
130+
// Packages with no linked repo (scorecard_score IS NULL) fall into 'critical'.
131+
if (opts.healthBand === 'healthy') {
132+
conditions.push('r_sc.scorecard_score >= 7.0')
133+
} else if (opts.healthBand === 'fair') {
134+
conditions.push('r_sc.scorecard_score >= 5.0 AND r_sc.scorecard_score < 7.0')
135+
} else if (opts.healthBand === 'concerning') {
136+
conditions.push('r_sc.scorecard_score >= 3.0 AND r_sc.scorecard_score < 5.0')
137+
} else {
138+
// critical band includes no-repo packages (NULL scorecard)
139+
conditions.push('(r_sc.scorecard_score IS NULL OR r_sc.scorecard_score < 3.0)')
140+
}
141+
}
142+
143+
if (opts.vulnSeverity) {
144+
if (opts.vulnSeverity === 'any') {
145+
conditions.push('ap_counts.cnt > 0')
146+
} else if (opts.vulnSeverity === 'high') {
147+
// high includes packages where worst severity is HIGH or CRITICAL
148+
conditions.push('ap_severity.max_rank >= 3')
149+
} else {
150+
// critical: worst severity is CRITICAL only
151+
conditions.push('ap_severity.max_rank >= 4')
152+
}
153+
}
154+
88155
if (opts.staleOnly) {
89156
conditions.push(
90157
`(p.latest_release_at IS NULL OR p.latest_release_at < NOW() - INTERVAL '${STALE_MONTHS} months')`,
@@ -102,16 +169,56 @@ export async function listPackagesForApi(
102169

103170
const where = `WHERE ${conditions.join(' AND ')}`
104171

105-
// health is a v2 field — fall back to name sort
106172
let sortExpr: string
107-
if (opts.sortBy === 'impact') sortExpr = 'p.impact'
108-
else if (opts.sortBy === 'openVulns') sortExpr = '"openVulns"'
109-
else sortExpr = 'LOWER(p.name)'
173+
if (opts.sortBy === 'impact') {
174+
sortExpr = 'p.impact'
175+
} else if (opts.sortBy === 'openVulns') {
176+
sortExpr = '"openVulns"'
177+
} else if (opts.sortBy === 'health') {
178+
sortExpr = 'r_sc.scorecard_score'
179+
} else if (opts.sortBy === 'risk') {
180+
// Composite risk score: impact + health deficit + vuln exposure + bus factor + staleness
181+
sortExpr = `(
182+
COALESCE(p.impact, 0) * 100
183+
+ (100.0 - COALESCE(r_sc.scorecard_score, 0) * 10) * 0.8
184+
+ COALESCE(ap_severity.max_rank, 0) * 15
185+
+ COALESCE(ap_counts.cnt, 0) * 4
186+
+ CASE WHEN pm_counts.cnt = 1 THEN 20 ELSE 0 END
187+
+ CASE WHEN (p.latest_release_at IS NULL OR p.latest_release_at < NOW() - INTERVAL '${STALE_MONTHS} months') THEN 15 ELSE 0 END
188+
)`
189+
} else {
190+
sortExpr = 'LOWER(p.name)'
191+
}
110192
const sortDir = opts.sortDir === 'desc' ? 'DESC' : 'ASC'
111193

112194
// Separate paginated params from filter-only params used by the fallback COUNT query
113195
const queryParams = { ...params, limit: opts.pageSize, offset: (opts.page - 1) * opts.pageSize }
114196

197+
// Shared LATERAL clauses — included in both the main query and the count fallback
198+
// so that WHERE conditions referencing them work in both paths.
199+
const laterals = `
200+
LEFT JOIN stewardships s ON s.package_id = p.id
201+
LEFT JOIN LATERAL (
202+
SELECT COUNT(*)::int AS cnt FROM advisory_packages WHERE package_id = p.id
203+
) ap_counts ON true
204+
LEFT JOIN LATERAL (
205+
SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id
206+
) pm_counts ON true
207+
LEFT JOIN LATERAL (
208+
SELECT ${SEVERITY_RANK_EXPR} AS max_rank
209+
FROM advisory_packages ap
210+
JOIN advisories a ON a.id = ap.advisory_id
211+
WHERE ap.package_id = p.id
212+
) ap_severity ON true
213+
LEFT JOIN LATERAL (
214+
SELECT r.scorecard_score
215+
FROM package_repos pr
216+
JOIN repos r ON r.id = pr.repo_id
217+
WHERE pr.package_id = p.id
218+
ORDER BY pr.confidence DESC
219+
LIMIT 1
220+
) r_sc ON true`
221+
115222
const rows: PackageListRow[] = await qx.select(
116223
`
117224
SELECT
@@ -122,15 +229,10 @@ export async function listPackagesForApi(
122229
s.status AS "stewardshipStatus",
123230
COALESCE(ap_counts.cnt, 0) AS "openVulns",
124231
pm_counts.cnt AS "maintainerCount",
232+
r_sc.scorecard_score AS "scorecardScore",
125233
COUNT(*) OVER() AS total
126234
FROM packages p
127-
LEFT JOIN stewardships s ON s.package_id = p.id
128-
LEFT JOIN LATERAL (
129-
SELECT COUNT(*)::int AS cnt FROM advisory_packages WHERE package_id = p.id
130-
) ap_counts ON true
131-
LEFT JOIN LATERAL (
132-
SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id
133-
) pm_counts ON true
235+
${laterals}
134236
${where}
135237
ORDER BY ${sortExpr} ${sortDir} NULLS LAST, p.purl ${sortDir}
136238
LIMIT $(limit) OFFSET $(offset)
@@ -147,10 +249,7 @@ export async function listPackagesForApi(
147249
const countRow: { count: string } = await qx.selectOne(
148250
`SELECT COUNT(*)::text AS count
149251
FROM packages p
150-
LEFT JOIN stewardships s ON s.package_id = p.id
151-
LEFT JOIN LATERAL (
152-
SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id
153-
) pm_counts ON true
252+
${laterals}
154253
${where}`,
155254
params,
156255
)

0 commit comments

Comments
 (0)